Fix mock tests

This commit is contained in:
Tour
2025-12-07 06:28:37 +01:00
parent f561a73b01
commit ef804b3896
18 changed files with 3055 additions and 56 deletions

View File

@@ -135,6 +135,10 @@ mvn exec:java -Dexec.mainClass="com.auction.scraper.TroostwijkScraper"
## System Architecture & Integration Flow ## System Architecture & Integration Flow
> **📊 Complete Integration Flowchart**: See [docs/INTEGRATION_FLOWCHART.md](docs/INTEGRATION_FLOWCHART.md) for the detailed intelligence integration diagram with GraphQL API fields, analytics, and dashboard features.
### Quick Overview
``` ```
┌─────────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPLETE SYSTEM INTEGRATION DIAGRAM │ │ COMPLETE SYSTEM INTEGRATION DIAGRAM │

View File

@@ -0,0 +1,393 @@
# Auctiora Intelligence Integration Flowchart
## Complete System Integration Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPLETE SYSTEM INTEGRATION DIAGRAM │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ PHASE 1: EXTERNAL SCRAPER (Python/Playwright) - ARCHITECTURE-TROOSTWIJK │
└──────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
[Listing Pages] [Auction Pages] [Lot Pages]
/auctions?page=N /a/auction-id /l/lot-id
│ │ │
│ Extract URLs │ Parse __NEXT_DATA__ │ Parse __NEXT_DATA__
├────────────────────────────▶│ JSON (GraphQL) │ JSON (GraphQL)
│ │ │
│ ▼ ▼
│ ┌────────────────┐ ┌────────────────┐
│ │ INSERT auctions│ │ INSERT lots │
│ │ to SQLite │ │ INSERT images │
│ └────────────────┘ │ (URLs only) │
│ │ └────────────────┘
│ │ │
└─────────────────────────────┴────────────────────────────┘
┌──────────────────┐
│ SQLITE DATABASE │
│ output/cache.db │
└──────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
[auctions table] [lots table] [images table]
- auction_id - lot_id - id
- title - auction_id - lot_id
- location - title - url
- lots_count - current_bid - local_path
- closing_time - bid_count - downloaded=0
- closing_time
- followersCount ⭐ NEW
- estimatedMin ⭐ NEW
- estimatedMax ⭐ NEW
- nextBidStepInCents ⭐ NEW
- condition ⭐ NEW
- vat ⭐ NEW
- buyerPremiumPercentage ⭐ NEW
- quantity ⭐ NEW
- biddingStatus ⭐ NEW
- remarks ⭐ NEW
┌─────────────────────────────────────┴─────────────────────────────────────┐
│ PHASE 2: MONITORING & PROCESSING (Java) - THIS PROJECT │
└────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
[TroostwijkMonitor] [DatabaseService] [ScraperDataAdapter]
│ │ │
│ Read lots │ Query lots │ Transform data
│ every hour │ Import images │ TEXT → INTEGER
│ │ │ "€123" → 123.0
└─────────────────┴─────────────────┘
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
[Bid Monitoring] [Image Processing] [Closing Alerts]
Check API every 1h Download images Check < 5 min
│ │ │
│ New bid? │ Process via │ Time critical?
├─[YES]──────────┐ │ ObjectDetection ├─[YES]────┐
│ │ │ │ │
▼ │ ▼ │ │
[Update current_bid] │ ┌──────────────────┐ │ │
in database │ │ YOLO Detection │ │ │
│ │ OpenCV DNN │ │ │
│ └──────────────────┘ │ │
│ │ │ │
│ │ Detect objects │ │
│ ├─[vehicle] │ │
│ ├─[furniture] │ │
│ ├─[machinery] │ │
│ │ │ │
│ ▼ │ │
│ [Save labels to DB] │ │
│ [Estimate value] │ │
│ │ │ │
│ │ │ │
└─────────┴───────────────────────┴──────────┘
┌───────────────────────────────────────────────┴────────────────────────────┐
│ PHASE 3: INTELLIGENCE LAYER ⭐ NEW - PREDICTIVE ANALYTICS │
└────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┴─────────────────┐
▼ ▼
[Intelligence Engine] [Analytics Calculations]
│ │
┌───────────────────┼──────────────┐ │
▼ ▼ ▼ │
[Sleeper Detection] [Bargain Finder] [Popularity Tracker] │
High followers Price < estimate Watch count analysis │
Low current bid Opportunity Competition level │
│ │ │ │
│ │ │ │
└───────────────────┴──────────────┴───────────────────┘
┌─────────────────┴─────────────────┐
▼ ▼
[Total Cost Calculator] [Next Bid Calculator]
Current bid × (1 + VAT/100) Current bid + increment
× (1 + premium/100) (from API or calculated)
│ │
└─────────────────┬─────────────────┘
┌───────────────────────────────────────────────┴────────────────────────────┐
│ PHASE 4: NOTIFICATION SYSTEM - USER INTERACTION TRIGGERS │
└────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┴─────────────────┐
▼ ▼
[NotificationService] [User Decision Points]
│ │
┌───────────────────┼───────────────────┐ │
▼ ▼ ▼ │
[Desktop Notify] [Email Notify] [Priority Level] │
Windows/macOS/ Gmail SMTP 0=Normal │
Linux system (FREE) 1=High │
tray │
│ │ │ │
└───────────────────┴───────────────────┘ │
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ USER INTERACTION │ │ TRIGGER EVENTS: │
│ NOTIFICATIONS │ │ │
└──────────────────┘ └──────────────────┘
│ │
┌───────────────────┼───────────────────┐ │
▼ ▼ ▼ │
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ 1. BID CHANGE │ │ 2. OBJECT │ │ 3. CLOSING │ │
│ │ │ DETECTED │ │ ALERT │ │
│ "Nieuw bod op │ │ │ │ │ │
│ kavel 12345: │ │ "Lot contains: │ │ "Kavel 12345 │ │
│ €150 (was €125)"│ │ - Vehicle │ │ sluit binnen │ │
│ │ │ - Machinery │ │ 5 min." │ │
│ Priority: NORMAL │ │ Est: €5000" │ │ Priority: HIGH │ │
│ │ │ │ │ │ │
│ Action needed: │ │ Action needed: │ │ Action needed: │ │
│ ▸ Place bid? │ │ ▸ Review item? │ │ ▸ Place final │ │
│ ▸ Monitor? │ │ ▸ Confirm value? │ │ bid? │ │
│ ▸ Ignore? │ │ ▸ Add to watch? │ │ ▸ Let expire? │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │ │ │
└───────────────────┴───────────────────┴─────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ INTELLIGENCE NOTIFICATIONS ⭐ NEW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 4. SLEEPER LOT ALERT │
│ "Lot 12345: 25 watchers, only €50 bid - Opportunity!" │
│ Action: ▸ Place strategic bid ▸ Monitor competition ▸ Set alert │
│ │
│ 5. BARGAIN DETECTED │
│ "Lot 67890: Current €200, Estimate €400-€600 - Below estimate!" │
│ Action: ▸ Bid now ▸ Research comparable ▸ Add to watchlist │
│ │
│ 6. HIGH COMPETITION WARNING │
│ "Lot 11111: 75 watchers, bid velocity 5/hr - Strong competition" │
│ Action: ▸ Review strategy ▸ Set max bid ▸ Find alternatives │
│ │
│ 7. TOTAL COST NOTIFICATION │
│ "True cost: €500 bid + €105 VAT (21%) + €50 premium (10%) = €655" │
│ Action: ▸ Confirm budget ▸ Adjust bid ▸ Calculate logistics │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Intelligence Dashboard Flow
```mermaid
flowchart TD
subgraph P1["PHASE 1: DATA COLLECTION"]
A1[GraphQL API] --> A2[Scraper Extracts 15+ New Fields]
A2 --> A3[followersCount]
A2 --> A4[estimatedMin/Max]
A2 --> A5[nextBidStepInCents]
A2 --> A6[vat + buyerPremiumPercentage]
A2 --> A7[condition + biddingStatus]
A3 & A4 & A5 & A6 & A7 --> DB[(SQLite Database)]
end
DB --> P2_Entry
subgraph P2["PHASE 2: INTELLIGENCE PROCESSING"]
P2_Entry[Lot.java Model] --> Intelligence[Intelligence Methods]
Intelligence --> Sleeper[isSleeperLot<br/>High followers, low bid]
Intelligence --> Bargain[isBelowEstimate<br/>Price < estimate]
Intelligence --> Popular[getPopularityLevel<br/>Watch count tiers]
Intelligence --> Cost[calculateTotalCost<br/>Bid + VAT + Premium]
Intelligence --> NextBid[calculateNextBid<br/>API increment]
end
P2_Entry --> API_Layer
subgraph API["PHASE 3: REST API ENDPOINTS"]
API_Layer[AuctionMonitorResource] --> E1[/intelligence/sleepers]
API_Layer --> E2[/intelligence/bargains]
API_Layer --> E3[/intelligence/popular]
API_Layer --> E4[/intelligence/price-analysis]
API_Layer --> E5[/lots/:id/intelligence]
API_Layer --> E6[/charts/watch-distribution]
end
E1 & E2 & E3 & E4 & E5 & E6 --> Dashboard
subgraph UI["PHASE 4: INTELLIGENCE DASHBOARD"]
Dashboard[index.html] --> Widget1[Sleeper Lots Widget<br/>Opportunities]
Dashboard --> Widget2[Bargain Lots Widget<br/>Below Estimate]
Dashboard --> Widget3[Popular Lots Widget<br/>High Competition]
Dashboard --> Table[Enhanced Table<br/>Watchers | Est. Range | Total Cost]
Table --> Badges[Smart Badges:<br/>DEAL | Watch Count | Time Left]
end
Widget1 --> UserAction
Widget2 --> UserAction
Widget3 --> UserAction
Table --> UserAction
subgraph Actions["PHASE 5: USER ACTIONS"]
UserAction[User Decision] --> Bid[Place Strategic Bid]
UserAction --> Monitor[Add to Watchlist]
UserAction --> Research[Research Comparables]
UserAction --> Calculate[Budget Calculator]
end
```
## Key Intelligence Features
### 1. Follower/Watch Count Analytics
- **Data Source**: `followersCount` from GraphQL API
- **Intelligence Value**:
- Predict lot popularity before bidding wars
- Calculate interest-to-bid conversion rates
- Identify "sleeper" lots (high followers, low bids)
- Alert on sudden interest spikes
### 2. Price vs Estimate Analysis
- **Data Source**: `estimatedMin`, `estimatedMax` from GraphQL API
- **Intelligence Value**:
- Identify bargains: `currentBid < estimatedMin`
- Identify overvalued: `currentBid > estimatedMax`
- Build pricing models per category
- Track auction house estimate accuracy
### 3. True Cost Calculator
- **Data Source**: `vat`, `buyerPremiumPercentage` from GraphQL API
- **Intelligence Value**:
- Calculate total cost: `bid × (1 + VAT/100) × (1 + premium/100)`
- Budget planning with accurate all-in costs
- Compare true costs across lots
- Prevent bidding surprises
### 4. Exact Bid Increment
- **Data Source**: `nextBidStepInCents` from GraphQL API
- **Intelligence Value**:
- Show exact next bid amount
- No calculation errors
- Better UX for bidding recommendations
- Strategic bid placement
### 5. Structured Location & Category
- **Data Source**: `cityLocation`, `countryCode`, `categoryPath` from GraphQL API
- **Intelligence Value**:
- Filter by distance from user
- Calculate pickup logistics costs
- Category-based analytics
- Regional pricing trends
## Integration Hooks & Timing
| Event | Frequency | Trigger | Notification Type | User Action Required |
|--------------------------------|-------------------|----------------------------|----------------------------|------------------------|
| **Sleeper lot detected** | On data refresh | followers > 10, bid < €100 | Desktop + Email | Review opportunity |
| **Bargain detected** | On data refresh | bid < estimatedMin | Desktop + Email | Consider bidding |
| **High competition** | On data refresh | followers > 50 | Desktop | Review strategy |
| **Bid change detected** | Every 1 hour | Monitor detects higher bid | Desktop + Email | Place counter-bid? |
| **Closing soon (< 30 min)** | When detected | Time-based check | Desktop + Email | Review lot |
| **Closing imminent (< 5 min)** | When detected | Time-based check | Desktop + Email (HIGH) | Final bid decision |
| **Object detected** | On image process | YOLO finds objects | Desktop + Email | Confirm identification |
| **True cost calculated** | On page load | User views lot | Dashboard display | Budget confirmation |
## API Endpoints Reference
### Intelligence Endpoints
- `GET /api/monitor/intelligence/sleepers` - Returns high-interest, low-bid lots
- `GET /api/monitor/intelligence/bargains` - Returns lots priced below estimate
- `GET /api/monitor/intelligence/popular?level={HIGH|MEDIUM|LOW}` - Returns lots by popularity
- `GET /api/monitor/intelligence/price-analysis` - Returns price vs estimate statistics
- `GET /api/monitor/lots/{lotId}/intelligence` - Returns detailed intelligence for specific lot
### Chart Endpoints
- `GET /api/monitor/charts/watch-distribution` - Returns follower count distribution
- `GET /api/monitor/charts/country-distribution` - Returns geographic distribution
- `GET /api/monitor/charts/category-distribution` - Returns category distribution
- `GET /api/monitor/charts/bidding-trend?hours=24` - Returns time series data
## Dashboard Intelligence Widgets
### Sleeper Lots Widget
- **Color**: Purple gradient
- **Icon**: Eye (fa-eye)
- **Metric**: Count of lots with followers > 10 and bid < €100
- **Action**: Click to filter table to sleeper lots only
### Bargain Lots Widget
- **Color**: Green gradient
- **Icon**: Tag (fa-tag)
- **Metric**: Count of lots where current bid < estimated minimum
- **Action**: Click to filter table to bargain lots only
### Popular/Hot Lots Widget
- **Color**: Orange gradient
- **Icon**: Fire (fa-fire)
- **Metric**: Count of lots with followers > 20
- **Action**: Click to filter table to popular lots only
## Enhanced Table Features
### New Columns
1. **Watchers** - Shows follower count with color-coded badges:
- 50+ followers: Red (high competition)
- 21-50 followers: Orange (medium competition)
- 6-20 followers: Blue (some interest)
- 0-5 followers: Gray (minimal interest)
2. **Est. Range** - Shows auction house estimate: `€min-€max`
- Displays "DEAL" badge if current bid < estimate
3. **Total Cost** - Shows true cost including VAT and buyer premium:
- Hover tooltip shows breakdown: `Including VAT (21%) + Premium (10%)`
### Smart Indicators
- **DEAL Badge**: Green badge when `currentBid < estimatedMin`
- **Watch Count Badge**: Color-coded by competition level
- **Urgency Badge**: Time-based coloring (< 10 min = red)
## Technical Implementation
### Backend (Java)
- **File**: `src/main/java/auctiora/Lot.java`
- Added 24 new fields from GraphQL API
- Added 9 intelligence calculation methods
- Immutable record with Lombok `@With` annotation
- **File**: `src/main/java/auctiora/AuctionMonitorResource.java`
- Added 6 new REST API endpoints
- Enhanced insights with sleeper/bargain/popular detection
- Added watch distribution chart endpoint
### Frontend (HTML/JavaScript)
- **File**: `src/main/resources/META-INF/resources/index.html`
- Added 3 intelligence widgets with click handlers
- Enhanced closing soon table with 3 new columns
- Added `fetchIntelligenceData()` function
- Added smart badges and color coding
- Added total cost calculator display
## Future Enhancements
1. **Bid History Table** - Track bid changes over time
2. **Comparative Analytics** - Compare similar lots across auctions
3. **Machine Learning** - Predict final hammer price based on patterns
4. **Geographic Filtering** - Distance-based sorting and filtering
5. **Email Alerts** - Custom alerts for sleepers, bargains, etc.
6. **Mobile App** - Push notifications for time-critical events
7. **Bid Automation** - Auto-bid up to maximum with increment logic
---
**Last Updated**: December 2025
**Version**: 2.1
**Author**: Auctiora Intelligence Team

View File

@@ -0,0 +1,422 @@
# Intelligence Features Implementation Summary
## Overview
This document summarizes the implementation of advanced intelligence features based on 15+ new GraphQL API fields discovered from the Troostwijk auction system.
## New GraphQL Fields Integrated
### HIGH PRIORITY FIELDS (Implemented)
1. **`followersCount`** (Integer) - Watch count showing bidder interest
- Direct indicator of competition
- Used for sleeper lot detection
- Popularity level classification
2. **`estimatedFullPrice`** (Object: min/max cents)
- Auction house's estimated value range
- Used for bargain detection
- Price vs estimate analytics
3. **`nextBidStepInCents`** (Long)
- Exact bid increment from API
- Precise next bid calculations
- Better UX for bidding recommendations
4. **`condition`** (String)
- Direct condition field from API
- Better than extracting from attributes
- Used in condition scoring
5. **`categoryInformation`** (Object)
- Structured category with path
- Better categorization and filtering
- Category-based analytics
6. **`location`** (Object: city, countryCode, etc.)
- Structured location data
- Proximity filtering capability
- Logistics cost calculation
### MEDIUM PRIORITY FIELDS (Implemented)
7. **`biddingStatus`** (Enum) - Detailed bidding status
8. **`appearance`** (String) - Visual condition notes
9. **`packaging`** (String) - Packaging details
10. **`quantity`** (Long) - Lot quantity for bulk items
11. **`vat`** (BigDecimal) - VAT percentage
12. **`buyerPremiumPercentage`** (BigDecimal) - Buyer premium
13. **`remarks`** (String) - Viewing/pickup notes
## Code Changes
### 1. Backend - Lot.java (Domain Model)
**File**: `src/main/java/auctiora/Lot.java`
**Changes**:
- Added 24 new fields to the Lot record
- Implemented 9 intelligence calculation methods:
- `calculateTotalCost()` - Bid + VAT + Premium
- `calculateNextBid()` - Using API increment
- `isBelowEstimate()` - Bargain detection
- `isAboveEstimate()` - Overvalued detection
- `getInterestToBidRatio()` - Conversion rate
- `getPopularityLevel()` - HIGH/MEDIUM/LOW/MINIMAL
- `isSleeperLot()` - High interest, low bid
- `getEstimatedMidpoint()` - Average of estimate range
- `getPriceVsEstimateRatio()` - Price comparison metric
**Example**:
```java
public boolean isSleeperLot() {
return followersCount != null && followersCount > 10 && currentBid < 100;
}
public double calculateTotalCost() {
double base = currentBid > 0 ? currentBid : 0;
if (vat != null && vat > 0) {
base += (base * vat / 100.0);
}
if (buyerPremiumPercentage != null && buyerPremiumPercentage > 0) {
base += (base * buyerPremiumPercentage / 100.0);
}
return base;
}
```
### 2. Backend - AuctionMonitorResource.java (REST API)
**File**: `src/main/java/auctiora/AuctionMonitorResource.java`
**New Endpoints Added**:
1. `GET /api/monitor/intelligence/sleepers` - Sleeper lots (high interest, low bids)
2. `GET /api/monitor/intelligence/bargains` - Bargain lots (below estimate)
3. `GET /api/monitor/intelligence/popular?level={HIGH|MEDIUM|LOW}` - Popular lots
4. `GET /api/monitor/intelligence/price-analysis` - Price vs estimate statistics
5. `GET /api/monitor/lots/{lotId}/intelligence` - Detailed lot intelligence
6. `GET /api/monitor/charts/watch-distribution` - Follower count distribution
**Enhanced Features**:
- Updated insights endpoint to include sleeper, bargain, and popular insights
- Added intelligent filtering and sorting for intelligence data
- Integrated new fields into existing statistics
**Example Endpoint**:
```java
@GET
@Path("/intelligence/sleepers")
public Response getSleeperLots(@QueryParam("minFollowers") @DefaultValue("10") int minFollowers) {
var allLots = db.getAllLots();
var sleepers = allLots.stream()
.filter(Lot::isSleeperLot)
.toList();
return Response.ok(Map.of(
"count", sleepers.size(),
"lots", sleepers
)).build();
}
```
### 3. Frontend - index.html (Intelligence Dashboard)
**File**: `src/main/resources/META-INF/resources/index.html`
**New UI Components**:
#### Intelligence Dashboard Widgets (3 new cards)
1. **Sleeper Lots Widget**
- Purple gradient design
- Shows count of high-interest, low-bid lots
- Click to filter table
2. **Bargain Lots Widget**
- Green gradient design
- Shows count of below-estimate lots
- Click to filter table
3. **Popular/Hot Lots Widget**
- Orange gradient design
- Shows count of high-follower lots
- Click to filter table
#### Enhanced Closing Soon Table
**New Columns Added**:
1. **Watchers** - Follower count with color-coded badges
- Red (50+ followers): High competition
- Orange (21-50): Medium competition
- Blue (6-20): Some interest
- Gray (0-5): Minimal interest
2. **Est. Range** - Auction house estimate (`€min-€max`)
- Shows "DEAL" badge if below estimate
3. **Total Cost** - True cost including VAT and premium
- Hover tooltip shows breakdown
- Purple color to stand out
**JavaScript Functions Added**:
- `fetchIntelligenceData()` - Fetches all intelligence metrics
- `showSleeperLots()` - Filters table to sleepers
- `showBargainLots()` - Filters table to bargains
- `showPopularLots()` - Filters table to popular
- Enhanced table rendering with smart badges
**Example Code**:
```javascript
// Calculate total cost (including VAT and premium)
const currentBid = lot.currentBid || 0;
const vat = lot.vat || 0;
const premium = lot.buyerPremiumPercentage || 0;
const totalCost = currentBid * (1 + (vat/100) + (premium/100));
// Bargain indicator
const isBargain = estMin && currentBid < parseFloat(estMin);
const bargainBadge = isBargain ?
'<span class="ml-1 text-xs bg-green-500 text-white px-1 rounded">DEAL</span>' : '';
```
## Intelligence Features
### 1. Sleeper Lot Detection
**Algorithm**: `followersCount > 10 AND currentBid < 100`
**Value Proposition**:
- Identifies lots with high interest but low current bids
- Opportunity to bid strategically before price escalates
- Early indicator of undervalued items
**Dashboard Display**:
- Count shown in purple widget
- Click to filter table
- Purple "eye" icon
### 2. Bargain Detection
**Algorithm**: `currentBid < estimatedMin`
**Value Proposition**:
- Identifies lots priced below auction house estimate
- Clear signal of potential good deals
- Quantifiable value assessment
**Dashboard Display**:
- Count shown in green widget
- "DEAL" badge in table
- Click to filter table
### 3. Popularity Analysis
**Algorithm**: Tiered classification by follower count
- HIGH: > 50 followers
- MEDIUM: 21-50 followers
- LOW: 6-20 followers
- MINIMAL: 0-5 followers
**Value Proposition**:
- Predict competition level
- Identify trending items
- Adjust bidding strategy accordingly
**Dashboard Display**:
- Count shown in orange widget
- Color-coded badges in table
- Click to filter by level
### 4. True Cost Calculator
**Algorithm**: `currentBid × (1 + VAT/100) × (1 + premium/100)`
**Value Proposition**:
- Shows actual out-of-pocket cost
- Prevents budget surprises
- Enables accurate comparison across lots
**Dashboard Display**:
- Purple "Total Cost" column
- Hover tooltip shows breakdown
- Updated in real-time
### 5. Exact Bid Increment
**Algorithm**: Uses `nextBidStepInCents` from API, falls back to calculated increment
**Value Proposition**:
- No guesswork on next bid amount
- API-provided accuracy
- Better bidding UX
**Implementation**:
```java
public double calculateNextBid() {
if (nextBidStepInCents != null && nextBidStepInCents > 0) {
return currentBid + (nextBidStepInCents / 100.0);
} else if (bidIncrement != null && bidIncrement > 0) {
return currentBid + bidIncrement;
}
return currentBid * 1.05; // Fallback: 5% increment
}
```
### 6. Price vs Estimate Analytics
**Metrics**:
- Total lots with estimates
- Count below estimate
- Count above estimate
- Average price vs estimate percentage
**Value Proposition**:
- Market efficiency analysis
- Auction house accuracy tracking
- Investment opportunity identification
**API Endpoint**: `/api/monitor/intelligence/price-analysis`
## Visual Design
### Color Scheme
- **Purple**: Sleeper lots, total cost (opportunity/value)
- **Green**: Bargains, deals (positive value)
- **Orange/Red**: Popular/hot lots (competition warning)
- **Blue**: Moderate interest (informational)
- **Gray**: Minimal interest (neutral)
### Badge System
1. **Watchers Badge**: Color-coded by competition level
2. **DEAL Badge**: Green indicator for below-estimate
3. **Time Left Badge**: Red/yellow/green by urgency
4. **Popularity Badge**: Fire icon for hot lots
### Interactive Elements
- Click widgets to filter table
- Hover for detailed tooltips
- Smooth scroll to table on filter
- Toast notifications for user feedback
## Performance Considerations
### API Optimization
- All intelligence data fetched in parallel
- Cached in dashboard state
- Minimal recalculation on render
- Efficient stream operations in backend
### Frontend Optimization
- Batch DOM updates
- Lazy rendering for large tables
- Debounced filter operations
- CSS transitions for smooth UX
## Testing Recommendations
### Backend Tests
1. Test `Lot` intelligence methods with various inputs
2. Test API endpoints with mock data
3. Test edge cases (null values, zero bids, etc.)
4. Performance test with 10k+ lots
### Frontend Tests
1. Test widget click handlers
2. Test table rendering with new columns
3. Test filter functionality
4. Test responsive design on mobile
### Integration Tests
1. End-to-end flow: Scraper → DB → API → Dashboard
2. Real-time data refresh
3. Concurrent user access
4. Load testing
## Future Enhancements
### Phase 2 (Bid History)
- Implement `bid_history` table scraping
- Track bid changes over time
- Calculate bid velocity accurately
- Identify bid patterns
### Phase 3 (ML Predictions)
- Predict final hammer price
- Recommend optimal bid timing
- Classify lot categories automatically
- Anomaly detection
### Phase 4 (Mobile)
- React Native mobile app
- Push notifications
- Offline mode
- Quick bid functionality
## Migration Guide
### Database Migration (Required)
The new fields need to be added to the database schema:
```sql
-- Add to lots table
ALTER TABLE lots ADD COLUMN followers_count INTEGER DEFAULT 0;
ALTER TABLE lots ADD COLUMN estimated_min DECIMAL(12, 2);
ALTER TABLE lots ADD COLUMN estimated_max DECIMAL(12, 2);
ALTER TABLE lots ADD COLUMN next_bid_step_in_cents BIGINT;
ALTER TABLE lots ADD COLUMN condition TEXT;
ALTER TABLE lots ADD COLUMN category_path TEXT;
ALTER TABLE lots ADD COLUMN city_location TEXT;
ALTER TABLE lots ADD COLUMN country_code TEXT;
ALTER TABLE lots ADD COLUMN bidding_status TEXT;
ALTER TABLE lots ADD COLUMN appearance TEXT;
ALTER TABLE lots ADD COLUMN packaging TEXT;
ALTER TABLE lots ADD COLUMN quantity BIGINT;
ALTER TABLE lots ADD COLUMN vat DECIMAL(5, 2);
ALTER TABLE lots ADD COLUMN buyer_premium_percentage DECIMAL(5, 2);
ALTER TABLE lots ADD COLUMN remarks TEXT;
ALTER TABLE lots ADD COLUMN starting_bid DECIMAL(12, 2);
ALTER TABLE lots ADD COLUMN reserve_price DECIMAL(12, 2);
ALTER TABLE lots ADD COLUMN reserve_met BOOLEAN DEFAULT FALSE;
ALTER TABLE lots ADD COLUMN bid_increment DECIMAL(12, 2);
ALTER TABLE lots ADD COLUMN view_count INTEGER DEFAULT 0;
ALTER TABLE lots ADD COLUMN first_bid_time TEXT;
ALTER TABLE lots ADD COLUMN last_bid_time TEXT;
ALTER TABLE lots ADD COLUMN bid_velocity DECIMAL(5, 2);
```
### Scraper Update (Required)
The external scraper (Python/Playwright) needs to extract the new fields from GraphQL:
```python
# Extract from __NEXT_DATA__ JSON
followers_count = lot_data.get('followersCount')
estimated_min = lot_data.get('estimatedFullPrice', {}).get('min', {}).get('cents')
estimated_max = lot_data.get('estimatedFullPrice', {}).get('max', {}).get('cents')
next_bid_step = lot_data.get('nextBidStepInCents')
condition = lot_data.get('condition')
# ... etc
```
### Deployment Steps
1. Stop the monitor service
2. Run database migrations
3. Update scraper to extract new fields
4. Deploy updated monitor JAR
5. Restart services
6. Verify data populating in dashboard
## Performance Metrics
### Expected Performance
- **Intelligence Data Fetch**: < 100ms for 10k lots
- **Table Rendering**: < 200ms with all new columns
- **Widget Update**: < 50ms
- **API Response Time**: < 500ms
### Resource Usage
- **Memory**: +50MB for intelligence calculations
- **Database**: +2KB per lot (new columns)
- **Network**: +10KB per dashboard refresh
## Documentation
- **Integration Flowchart**: `docs/INTEGRATION_FLOWCHART.md`
- **API Documentation**: Auto-generated from JAX-RS annotations
- **Database Schema**: `wiki/DATABASE_ARCHITECTURE.md`
- **GraphQL Fields**: `wiki/EXPERT_ANALITICS.sql`
---
**Implementation Date**: December 2025
**Version**: 2.1
**Status**: ✅ Complete - Ready for Testing
**Next Steps**:
1. Deploy to staging environment
2. Run integration tests
3. Update scraper to extract new fields
4. Deploy to production

View File

@@ -502,13 +502,45 @@ public class AuctionMonitorResource {
.max(Map.Entry.comparingByValue()) .max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.orElse("N/A"); .orElse("N/A");
insights.add(Map.of( insights.add(Map.of(
"icon", "fa-globe", "icon", "fa-globe",
"title", topCountry + " leading", "title", topCountry + " leading",
"description", "Top performing country" "description", "Top performing country"
)); ));
// Add sleeper lots insight
long sleeperCount = lots.stream().filter(Lot::isSleeperLot).count();
if (sleeperCount > 0) {
insights.add(Map.of(
"icon", "fa-eye",
"title", sleeperCount + " sleeper lots",
"description", "High interest, low bids - opportunity?"
));
}
// Add bargain insight
long bargainCount = lots.stream().filter(Lot::isBelowEstimate).count();
if (bargainCount > 5) {
insights.add(Map.of(
"icon", "fa-tag",
"title", bargainCount + " bargains",
"description", "Priced below auction house estimates"
));
}
// Add watch/followers insight
long highWatchCount = lots.stream()
.filter(l -> l.followersCount() != null && l.followersCount() > 20)
.count();
if (highWatchCount > 0) {
insights.add(Map.of(
"icon", "fa-fire",
"title", highWatchCount + " hot lots",
"description", "High follower count, strong competition"
));
}
return Response.ok(insights).build(); return Response.ok(insights).build();
} catch (Exception e) { } catch (Exception e) {
LOG.error("Failed to get insights", e); LOG.error("Failed to get insights", e);
@@ -518,13 +550,218 @@ public class AuctionMonitorResource {
} }
} }
/**
* GET /api/monitor/intelligence/sleepers
* Returns "sleeper" lots (high watch count, low bids)
*/
@GET
@Path("/intelligence/sleepers")
public Response getSleeperLots(@QueryParam("minFollowers") @DefaultValue("10") int minFollowers) {
try {
var allLots = db.getAllLots();
var sleepers = allLots.stream()
.filter(Lot::isSleeperLot)
.toList();
Map<String, Object> response = Map.of(
"count", sleepers.size(),
"lots", sleepers
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get sleeper lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/bargains
* Returns lots priced below auction house estimates
*/
@GET
@Path("/intelligence/bargains")
public Response getBargains() {
try {
var allLots = db.getAllLots();
var bargains = allLots.stream()
.filter(Lot::isBelowEstimate)
.sorted((a, b) -> {
Double ratioA = a.getPriceVsEstimateRatio();
Double ratioB = b.getPriceVsEstimateRatio();
if (ratioA == null) return 1;
if (ratioB == null) return -1;
return ratioA.compareTo(ratioB);
})
.toList();
Map<String, Object> response = Map.of(
"count", bargains.size(),
"lots", bargains
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get bargains", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/popular
* Returns lots by popularity level
*/
@GET
@Path("/intelligence/popular")
public Response getPopularLots(@QueryParam("level") @DefaultValue("HIGH") String level) {
try {
var allLots = db.getAllLots();
var popular = allLots.stream()
.filter(lot -> level.equalsIgnoreCase(lot.getPopularityLevel()))
.sorted((a, b) -> {
Integer followersA = a.followersCount() != null ? a.followersCount() : 0;
Integer followersB = b.followersCount() != null ? b.followersCount() : 0;
return followersB.compareTo(followersA);
})
.toList();
Map<String, Object> response = Map.of(
"count", popular.size(),
"level", level,
"lots", popular
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get popular lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/intelligence/price-analysis
* Returns price vs estimate analysis
*/
@GET
@Path("/intelligence/price-analysis")
public Response getPriceAnalysis() {
try {
var allLots = db.getAllLots();
long belowEstimate = allLots.stream().filter(Lot::isBelowEstimate).count();
long aboveEstimate = allLots.stream().filter(Lot::isAboveEstimate).count();
long withEstimates = allLots.stream()
.filter(lot -> lot.estimatedMin() != null && lot.estimatedMax() != null)
.count();
double avgPriceVsEstimate = allLots.stream()
.map(Lot::getPriceVsEstimateRatio)
.filter(ratio -> ratio != null)
.mapToDouble(Double::doubleValue)
.average()
.orElse(0.0);
Map<String, Object> response = Map.of(
"totalLotsWithEstimates", withEstimates,
"belowEstimate", belowEstimate,
"aboveEstimate", aboveEstimate,
"averagePriceVsEstimatePercent", Math.round(avgPriceVsEstimate),
"bargainOpportunities", belowEstimate
);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Failed to get price analysis", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots/{lotId}/intelligence
* Returns detailed intelligence for a specific lot
*/
@GET
@Path("/lots/{lotId}/intelligence")
public Response getLotIntelligence(@PathParam("lotId") long lotId) {
try {
var lot = db.getAllLots().stream()
.filter(l -> l.lotId() == lotId)
.findFirst()
.orElse(null);
if (lot == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Lot not found"))
.build();
}
Map<String, Object> intelligence = new HashMap<>();
intelligence.put("lotId", lot.lotId());
intelligence.put("followersCount", lot.followersCount());
intelligence.put("popularityLevel", lot.getPopularityLevel());
intelligence.put("estimatedMidpoint", lot.getEstimatedMidpoint());
intelligence.put("priceVsEstimatePercent", lot.getPriceVsEstimateRatio());
intelligence.put("isBargain", lot.isBelowEstimate());
intelligence.put("isOvervalued", lot.isAboveEstimate());
intelligence.put("isSleeperLot", lot.isSleeperLot());
intelligence.put("nextBidAmount", lot.calculateNextBid());
intelligence.put("totalCostWithFees", lot.calculateTotalCost());
intelligence.put("viewCount", lot.viewCount());
intelligence.put("bidVelocity", lot.bidVelocity());
intelligence.put("condition", lot.condition());
intelligence.put("vat", lot.vat());
intelligence.put("buyerPremium", lot.buyerPremiumPercentage());
return Response.ok(intelligence).build();
} catch (Exception e) {
LOG.error("Failed to get lot intelligence", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/charts/watch-distribution
* Returns follower/watch count distribution
*/
@GET
@Path("/charts/watch-distribution")
public Response getWatchDistribution() {
try {
var lots = db.getAllLots();
Map<String, Long> distribution = new HashMap<>();
distribution.put("0 watchers", lots.stream().filter(l -> l.followersCount() == null || l.followersCount() == 0).count());
distribution.put("1-5 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 1 && l.followersCount() <= 5).count());
distribution.put("6-20 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 6 && l.followersCount() <= 20).count());
distribution.put("21-50 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 21 && l.followersCount() <= 50).count());
distribution.put("50+ watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() > 50).count());
return Response.ok(distribution).build();
} catch (Exception e) {
LOG.error("Failed to get watch distribution", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
// Helper class for trend data // Helper class for trend data
public static class TrendHour { public static class TrendHour {
public int hour; public int hour;
public int lots; public int lots;
public int bids; public int bids;
public TrendHour(int hour, int lots, int bids) { public TrendHour(int hour, int lots, int bids) {
this.hour = hour; this.hour = hour;
this.lots = lots; this.lots = lots;

View File

@@ -573,7 +573,12 @@ public class DatabaseService {
rs.getString("currency"), rs.getString("currency"),
rs.getString("url"), rs.getString("url"),
closing, closing,
rs.getInt("closing_notified") != 0 rs.getInt("closing_notified") != 0,
// New intelligence fields - set to null for now
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null
)); ));
} }
} }

View File

@@ -4,11 +4,9 @@ import lombok.With;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /// Represents a lot (kavel) in an auction.
* Represents a lot (kavel) in an auction. /// Data typically populated by the external scraper process.
* Data typically populated by the external scraper process. /// This project enriches the data with image analysis and monitoring.
* This project enriches the data with image analysis and monitoring.
*/
@With @With
record Lot( record Lot(
long saleId, long saleId,
@@ -23,23 +21,132 @@ record Lot(
String currency, String currency,
String url, String url,
LocalDateTime closingTime, LocalDateTime closingTime,
boolean closingNotified boolean closingNotified,
// HIGH PRIORITY FIELDS from GraphQL API
Integer followersCount, // Watch count - direct competition indicator
Double estimatedMin, // Auction house min estimate (cents)
Double estimatedMax, // Auction house max estimate (cents)
Long nextBidStepInCents, // Exact bid increment from API
String condition, // Direct condition field
String categoryPath, // Structured category (e.g., "Vehicles > Cars > Classic")
String cityLocation, // Structured location
String countryCode, // ISO country code
// MEDIUM PRIORITY FIELDS
String biddingStatus, // More detailed than minimumBidAmountMet
String appearance, // Visual condition notes
String packaging, // Packaging details
Long quantity, // Lot quantity (bulk lots)
Double vat, // VAT percentage
Double buyerPremiumPercentage, // Buyer premium
String remarks, // Viewing/pickup notes
// BID INTELLIGENCE FIELDS
Double startingBid, // Starting/opening bid
Double reservePrice, // Reserve price (if disclosed)
Boolean reserveMet, // Reserve met status
Double bidIncrement, // Calculated bid increment
Integer viewCount, // Number of views
LocalDateTime firstBidTime, // First bid timestamp
LocalDateTime lastBidTime, // Last bid timestamp
Double bidVelocity, // Bids per hour
Double condition_score,
//Integer manufacturing_year,
Integer provenance_docs
) { ) {
public Integer provenanceDocs() { return provenance_docs; }
/// manufacturing_year
public Integer manufacturingYear() { return year; }
public Double conditionScore() { return condition_score; }
public long minutesUntilClose() { public long minutesUntilClose() {
if (closingTime == null) return Long.MAX_VALUE; if (closingTime == null) return Long.MAX_VALUE;
return Duration.between(LocalDateTime.now(), closingTime).toMinutes(); return Duration.between(LocalDateTime.now(), closingTime).toMinutes();
} }
public Lot withCurrentBid(double newBid) { // Intelligence Methods
return new Lot(saleId, lotId, title, description, /// Calculate total cost including VAT and buyer premium
manufacturer, type, year, category, public double calculateTotalCost() {
newBid, currency, url, closingTime, closingNotified); double base = currentBid > 0 ? currentBid : 0;
if (vat != null && vat > 0) {
base += (base * vat / 100.0);
}
if (buyerPremiumPercentage != null && buyerPremiumPercentage > 0) {
base += (base * buyerPremiumPercentage / 100.0);
}
return base;
} }
public Lot withClosingNotified(boolean flag) { /// Calculate next bid amount using API-provided increment
return new Lot(saleId, lotId, title, description, public double calculateNextBid() {
manufacturer, type, year, category, if (nextBidStepInCents != null && nextBidStepInCents > 0) {
currentBid, currency, url, closingTime, flag); return currentBid + (nextBidStepInCents / 100.0);
} else if (bidIncrement != null && bidIncrement > 0) {
return currentBid + bidIncrement;
}
// Fallback: 5% increment
return currentBid * 1.05;
} }
/// Check if current bid is below estimate (potential bargain)
public boolean isBelowEstimate() {
if (estimatedMin == null || estimatedMin == 0) return false;
return currentBid < (estimatedMin / 100.0);
}
/// Check if current bid exceeds estimate (overvalued)
public boolean isAboveEstimate() {
if (estimatedMax == null || estimatedMax == 0) return false;
return currentBid > (estimatedMax / 100.0);
}
/// Calculate interest-to-bid conversion rate
public double getInterestToBidRatio() {
if (followersCount == null || followersCount == 0) return 0.0;
return currentBid > 0 ? 100.0 : 0.0;
}
/// Determine lot popularity level
public String getPopularityLevel() {
if (followersCount == null) return "UNKNOWN";
if (followersCount > 50) return "HIGH";
if (followersCount > 20) return "MEDIUM";
if (followersCount > 5) return "LOW";
return "MINIMAL";
}
/// Check if lot is a "sleeper" (high interest, low bids)
public boolean isSleeperLot() {
return followersCount != null && followersCount > 10 && currentBid < 100;
}
/// Calculate estimated value range midpoint
public Double getEstimatedMidpoint() {
if (estimatedMin == null || estimatedMax == null) return null;
return (estimatedMin + estimatedMax) / 200.0; // Convert from cents
}
/// Calculate price vs estimate ratio (for analytics)
public Double getPriceVsEstimateRatio() {
Double midpoint = getEstimatedMidpoint();
if (midpoint == null || midpoint == 0 || currentBid == 0) return null;
return (currentBid / midpoint) * 100.0;
}
/// Factory method for creating a basic Lot without intelligence fields (for tests and backward compatibility)
public static Lot basic(
long saleId, long lotId, String title, String description,
String manufacturer, String type, int year, String category,
double currentBid, String currency, String url,
LocalDateTime closingTime, boolean closingNotified) {
return new Lot(
saleId, lotId, title, description, manufacturer, type, year, category,
currentBid, currency, url, closingTime, closingNotified,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null
);
}
} }

View File

@@ -193,7 +193,7 @@ public class QuarkusWorkflowScheduler {
notifier.sendNotification(message, "Lot Closing Soon", 1); notifier.sendNotification(message, "Lot Closing Soon", 1);
// Mark as notified // Mark as notified
var updated = new Lot( var updated = Lot.basic(
lot.saleId(), lot.lotId(), lot.title(), lot.description(), lot.saleId(), lot.lotId(), lot.title(), lot.description(),
lot.manufacturer(), lot.type(), lot.year(), lot.category(), lot.manufacturer(), lot.type(), lot.year(), lot.category(),
lot.currentBid(), lot.currency(), lot.url(), lot.currentBid(), lot.currency(), lot.url(),

View File

@@ -67,7 +67,12 @@ public class ScraperDataAdapter {
currency, currency,
rs.getString("url"), rs.getString("url"),
closing, closing,
false false,
// New intelligence fields - set to null for now
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null
); );
} }

View File

@@ -0,0 +1,364 @@
package auctiora;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* REST API for Auction Valuation Analytics
* Implements the mathematical framework for fair market value calculation,
* undervaluation detection, and bidding strategy recommendations.
*/
@Path("/api/analytics")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ValuationAnalyticsResource {
private static final Logger LOG = Logger.getLogger(ValuationAnalyticsResource.class);
@Inject
DatabaseService db;
/**
* POST /api/analytics/valuation
* Main valuation endpoint that calculates FMV, undervaluation score,
* predicted final price, and bidding strategy
*/
@POST
@Path("/valuation")
public Response calculateValuation(ValuationRequest request) {
try {
LOG.infof("Valuation request for lot: %s", request.lotId);
long startTime = System.currentTimeMillis();
// Step 1: Fetch comparable sales from database
List<ComparableLot> comparables = fetchComparables(request);
// Step 2: Calculate Fair Market Value (FMV)
FairMarketValue fmv = calculateFairMarketValue(request, comparables);
// Step 3: Calculate undervaluation score
double undervaluationScore = calculateUndervaluationScore(request, fmv.value);
// Step 4: Predict final price
PricePrediction prediction = calculateFinalPrice(request, fmv.value);
// Step 5: Generate bidding strategy
BiddingStrategy strategy = generateBiddingStrategy(request, fmv, prediction);
// Step 6: Compile response
ValuationResponse response = new ValuationResponse();
response.lotId = request.lotId;
response.timestamp = LocalDateTime.now().toString();
response.fairMarketValue = fmv;
response.undervaluationScore = undervaluationScore;
response.pricePrediction = prediction;
response.biddingStrategy = strategy;
response.parameters = request;
long duration = System.currentTimeMillis() - startTime;
LOG.infof("Valuation completed in %d ms", duration);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("Valuation calculation failed", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* Fetches comparable lots from database based on category, manufacturer,
* year, and condition similarity
*/
private List<ComparableLot> fetchComparables(ValuationRequest req) {
// TODO: Replace with actual database query
// For now, return mock data simulating real comparables
return List.of(
new ComparableLot("NL-2023-4451", 8200.0, 8.0, 2016, 1, 30),
new ComparableLot("BE-2023-9823", 7800.0, 7.0, 2014, 0, 45),
new ComparableLot("DE-2024-1234", 8500.0, 9.0, 2017, 1, 60),
new ComparableLot("NL-2023-5678", 7500.0, 6.0, 2013, 0, 25),
new ComparableLot("BE-2024-7890", 7900.0, 7.5, 2015, 1, 15),
new ComparableLot("NL-2023-2345", 8100.0, 8.5, 2016, 0, 40),
new ComparableLot("DE-2024-4567", 8300.0, 7.0, 2015, 1, 55),
new ComparableLot("BE-2023-3456", 7700.0, 6.5, 2014, 0, 35)
);
}
/**
* Formula: FMV = Σ(P_i · ω_c · ω_t · ω_p · ω_h) / Σ(ω_c · ω_t · ω_p · ω_h)
* Where weights are exponential/logistic functions of similarity
*/
private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) {
double weightedSum = 0.0;
double weightSum = 0.0;
List<WeightedComparable> weightedComps = new ArrayList<>();
for (ComparableLot comp : comparables) {
// Condition weight: ω_c = exp(-λ_c · |C_target - C_i|)
double omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore));
// Time weight: ω_t = exp(-λ_t · |T_target - T_i|)
double omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear));
// Provenance weight: ω_p = 1 + δ_p · (P_target - P_i)
double omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance);
// Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median)))
double omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40)));
double totalWeight = omegaC * omegaT * omegaP * omegaH;
weightedSum += comp.finalPrice * totalWeight;
weightSum += totalWeight;
// Store for transparency
weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH));
}
double baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2;
// Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c)
double conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40);
baseFMV *= conditionMultiplier;
// Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs))
if (req.provenanceDocs > 0) {
double provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs);
baseFMV *= (1 + provenancePremium);
}
FairMarketValue fmv = new FairMarketValue();
fmv.value = Math.round(baseFMV * 100.0) / 100.0;
fmv.conditionMultiplier = Math.round(conditionMultiplier * 1000.0) / 1000.0;
fmv.provenancePremium = req.provenanceDocs > 0 ? 0.08 + 0.035 * Math.log(1 + req.provenanceDocs) : 0.0;
fmv.comparablesUsed = comparables.size();
fmv.confidence = calculateFMVConfidence(comparables.size(), weightSum);
fmv.weightedComparables = weightedComps;
return fmv;
}
/**
* Calculates undervaluation score:
* U_score = (FMV - P_current)/FMV · σ_market · (1 + B_velocity/10) · ln(1 + W_watch/W_bid)
*/
private double calculateUndervaluationScore(ValuationRequest req, double fmv) {
if (fmv <= 0) return 0.0;
double priceGap = (fmv - req.currentBid) / fmv;
double velocityFactor = 1 + req.bidVelocity / 10.0;
double watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1));
double uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio;
return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0);
}
/**
* Predicts final price: P̂_final = FMV · (1 + ε_bid + ε_time + ε_comp)
* Where each epsilon represents auction dynamics
*/
private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) {
// Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/FMV)
double epsilonBid = Math.tanh(0.15 * req.bidVelocity - 0.10 * (req.currentBid / fmv));
// Time pressure error: ε_time = ψ · exp(-t_close/30)
double epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0);
// Competition error: ε_comp = ρ · ln(1 + W_watch/50)
double epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0);
double predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
// 95% confidence interval: ± 1.96 · σ_residual
double residualStdDev = fmv * 0.08; // Mock residual standard deviation
double ciLower = predictedPrice - 1.96 * residualStdDev;
double ciUpper = predictedPrice + 1.96 * residualStdDev;
PricePrediction pred = new PricePrediction();
pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0;
pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0;
pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0;
pred.components = Map.of(
"bidMomentum", Math.round(epsilonBid * 1000.0) / 1000.0,
"timePressure", Math.round(epsilonTime * 1000.0) / 1000.0,
"competition", Math.round(epsilonComp * 1000.0) / 1000.0
);
return pred;
}
/**
* Generates optimal bidding strategy based on market conditions
*/
private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) {
BiddingStrategy strategy = new BiddingStrategy();
// Determine competition level
if (req.bidVelocity > 5.0) {
strategy.competitionLevel = "HIGH";
strategy.recommendedTiming = "FINAL_30_SECONDS";
strategy.maxBid = pred.predictedPrice + 50; // Slight overbid for hot lots
strategy.riskFactors = List.of("Bidding war likely", "Sniping detected");
} else if (req.minutesUntilClose < 10) {
strategy.competitionLevel = "EXTREME";
strategy.recommendedTiming = "FINAL_10_SECONDS";
strategy.maxBid = pred.predictedPrice * 1.02;
strategy.riskFactors = List.of("Last-minute sniping", "Price volatility");
} else {
strategy.competitionLevel = "MEDIUM";
strategy.recommendedTiming = "FINAL_10_MINUTES";
// Adjust max bid based on undervaluation
double undervaluationScore = calculateUndervaluationScore(req, fmv.value);
if (undervaluationScore > 0.25) {
// Aggressive strategy for undervalued lots
strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid
strategy.analysis = "Significant undervaluation detected. Consider aggressive bidding.";
} else {
// Standard strategy
strategy.maxBid = fmv.value * (1 + 0.03);
}
strategy.riskFactors = List.of("Standard competition level");
}
// Generate detailed analysis
strategy.analysis = String.format(
"Bid velocity is %.1f bids/min with %d watchers. %s competition detected. " +
"Predicted final: €%.2f (%.0f%% confidence).",
req.bidVelocity,
req.watchCount,
strategy.competitionLevel,
pred.predictedPrice,
fmv.confidence * 100
);
// Round the max bid
strategy.maxBid = Math.round(strategy.maxBid * 100.0) / 100.0;
strategy.recommendedTimingText = strategy.recommendedTiming.replace("_", " ");
return strategy;
}
/**
* Calculates confidence score based on number and quality of comparables
*/
private double calculateFMVConfidence(int comparableCount, double totalWeight) {
double confidence = 0.5; // Base confidence
// Boost for more comparables
confidence += Math.min(comparableCount * 0.05, 0.3);
// Boost for high total weight (good matches)
confidence += Math.min(totalWeight / comparableCount * 0.1, 0.2);
// Cap at 0.95
return Math.min(confidence, 0.95);
}
// ================== DTO Classes ==================
public static class ValuationRequest {
public String lotId;
public double currentBid;
public double conditionScore; // C_target ∈ [0,10]
public int manufacturingYear; // T_target
public int watchCount; // W_watch
public int bidCount = 1; // W_bid (default 1 to avoid division by zero)
public double marketVolatility = 0.15; // σ_market ∈ [0,1]
public double bidVelocity; // Λ_b (bids/min)
public int minutesUntilClose; // t_close
public int provenanceDocs = 0; // N_docs
public double estimatedMin;
public double estimatedMax;
// Optional: override parameters for sensitivity analysis
public Map<String, Double> sensitivityParams;
}
public static class ValuationResponse {
public String lotId;
public String timestamp;
public FairMarketValue fairMarketValue;
public double undervaluationScore;
public PricePrediction pricePrediction;
public BiddingStrategy biddingStrategy;
public ValuationRequest parameters;
public long calculationTimeMs;
}
public static class FairMarketValue {
public double value;
public double conditionMultiplier;
public double provenancePremium;
public int comparablesUsed;
public double confidence; // [0,1]
public List<WeightedComparable> weightedComparables;
}
public static class WeightedComparable {
public String comparableLotId;
public double finalPrice;
public double totalWeight;
public Map<String, Double> components;
public WeightedComparable(ComparableLot comp, double totalWeight, double omegaC, double omegaT, double omegaP, double omegaH) {
this.comparableLotId = comp.lotId;
this.finalPrice = comp.finalPrice;
this.totalWeight = Math.round(totalWeight * 1000.0) / 1000.0;
this.components = Map.of(
"conditionWeight", Math.round(omegaC * 1000.0) / 1000.0,
"timeWeight", Math.round(omegaT * 1000.0) / 1000.0,
"provenanceWeight", Math.round(omegaP * 1000.0) / 1000.0,
"historicalWeight", Math.round(omegaH * 1000.0) / 1000.0
);
}
}
public static class PricePrediction {
public double predictedPrice;
public double confidenceIntervalLower;
public double confidenceIntervalUpper;
public Map<String, Double> components; // ε_bid, ε_time, ε_comp
}
public static class BiddingStrategy {
public String competitionLevel; // LOW, MEDIUM, HIGH, EXTREME
public double maxBid;
public String recommendedTiming; // FINAL_10_MINUTES, FINAL_30_SECONDS, etc.
public String recommendedTimingText;
public String analysis;
public List<String> riskFactors;
}
// Helper class for internal comparable representation
private static class ComparableLot {
String lotId;
double finalPrice;
double conditionScore;
int manufacturingYear;
int hasProvenance;
int daysAgo;
public ComparableLot(String lotId, double finalPrice, double conditionScore, int manufacturingYear, int hasProvenance, int daysAgo) {
this.lotId = lotId;
this.finalPrice = finalPrice;
this.conditionScore = conditionScore;
this.manufacturingYear = manufacturingYear;
this.hasProvenance = hasProvenance;
this.daysAgo = daysAgo;
}
}
}

View File

@@ -251,7 +251,7 @@ public class WorkflowOrchestrator {
notifier.sendNotification(message, "Lot Closing Soon", 1); notifier.sendNotification(message, "Lot Closing Soon", 1);
// Mark as notified // Mark as notified
var updated = new Lot( var updated = Lot.basic(
lot.saleId(), lot.lotId(), lot.title(), lot.description(), lot.saleId(), lot.lotId(), lot.title(), lot.description(),
lot.manufacturer(), lot.type(), lot.year(), lot.category(), lot.manufacturer(), lot.type(), lot.year(), lot.category(),
lot.currentBid(), lot.currency(), lot.url(), lot.currentBid(), lot.currency(), lot.url(),

View File

@@ -267,6 +267,63 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Intelligence Dashboard - NEW -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Sleeper Lots -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl shadow-md p-6 card-hover">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-semibold text-purple-900 flex items-center">
<i class="fas fa-eye mr-2"></i>Sleeper Lots
</h3>
<p class="text-xs text-purple-700 mt-1">High interest, low bids</p>
</div>
<div class="text-3xl font-bold text-purple-600" id="sleeper-count">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<button onclick="showSleeperLots()" class="w-full bg-purple-600 text-white py-2 rounded-lg hover:bg-purple-700 transition">
<i class="fas fa-search mr-2"></i>View Opportunities
</button>
</div>
<!-- Bargain Lots -->
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-xl shadow-md p-6 card-hover">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-semibold text-green-900 flex items-center">
<i class="fas fa-tag mr-2"></i>Bargains
</h3>
<p class="text-xs text-green-700 mt-1">Below estimate</p>
</div>
<div class="text-3xl font-bold text-green-600" id="bargain-count">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<button onclick="showBargainLots()" class="w-full bg-green-600 text-white py-2 rounded-lg hover:bg-green-700 transition">
<i class="fas fa-search mr-2"></i>Find Deals
</button>
</div>
<!-- Popular Lots -->
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-xl shadow-md p-6 card-hover">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-semibold text-orange-900 flex items-center">
<i class="fas fa-fire mr-2"></i>Hot Lots
</h3>
<p class="text-xs text-orange-700 mt-1">High competition</p>
</div>
<div class="text-3xl font-bold text-orange-600" id="popular-count">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<button onclick="showPopularLots()" class="w-full bg-orange-600 text-white py-2 rounded-lg hover:bg-orange-700 transition">
<i class="fas fa-search mr-2"></i>View Trending
</button>
</div>
</div>
<!-- Statistics & Performance --> <!-- Statistics & Performance -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
@@ -548,11 +605,17 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('title')"> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('title')">
Title <i class="fas fa-sort ml-1"></i> Title <i class="fas fa-sort ml-1"></i>
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('followersCount')">
Watchers <i class="fas fa-sort ml-1"></i>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('currentBid')"> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('currentBid')">
Current Bid <i class="fas fa-sort ml-1"></i> Current Bid <i class="fas fa-sort ml-1"></i>
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Closing Time Est. Range
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Cost
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('minutesUntilClose')"> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" onclick="sortTable('minutesUntilClose')">
Time Left <i class="fas fa-sort ml-1"></i> Time Left <i class="fas fa-sort ml-1"></i>
@@ -641,7 +704,10 @@ let dashboardState = {
closingSoon: [], closingSoon: [],
countryDistribution: {}, countryDistribution: {},
categoryDistribution: {}, categoryDistribution: {},
trendData: {} trendData: {},
sleepers: [],
bargains: [],
popular: []
}, },
filters: { filters: {
auction: '', auction: '',
@@ -748,7 +814,8 @@ async function fetchAllData() {
fetchStatistics(), fetchStatistics(),
fetchRateLimitStats(), fetchRateLimitStats(),
fetchClosingSoon(), fetchClosingSoon(),
fetchChartData() fetchChartData(),
fetchIntelligenceData()
]); ]);
updateLastUpdate(); updateLastUpdate();
updateDataAge(); updateDataAge();
@@ -766,6 +833,38 @@ async function fetchAllData() {
} }
} }
// Fetch intelligence data
async function fetchIntelligenceData() {
try {
// Fetch sleepers
const sleepersRes = await fetch('/api/monitor/intelligence/sleepers');
if (sleepersRes.ok) {
const sleepersData = await sleepersRes.json();
document.getElementById('sleeper-count').textContent = sleepersData.count || 0;
dashboardState.data.sleepers = sleepersData.lots || [];
}
// Fetch bargains
const bargainsRes = await fetch('/api/monitor/intelligence/bargains');
if (bargainsRes.ok) {
const bargainsData = await bargainsRes.json();
document.getElementById('bargain-count').textContent = bargainsData.count || 0;
dashboardState.data.bargains = bargainsData.lots || [];
}
// Fetch popular lots
const popularRes = await fetch('/api/monitor/intelligence/popular?level=HIGH');
if (popularRes.ok) {
const popularData = await popularRes.json();
document.getElementById('popular-count').textContent = popularData.count || 0;
dashboardState.data.popular = popularData.lots || [];
}
} catch (error) {
console.error('Intelligence data fetch error:', error);
}
}
// Fetch system status with trends // Fetch system status with trends
async function fetchStatus() { async function fetchStatus() {
try { try {
@@ -1179,12 +1278,41 @@ function updateClosingSoonTable(data = null) {
const urgencyIcon = minutesLeft < 10 ? 'fa-exclamation-circle' : const urgencyIcon = minutesLeft < 10 ? 'fa-exclamation-circle' :
minutesLeft < 20 ? 'fa-exclamation-triangle' : 'fa-clock'; minutesLeft < 20 ? 'fa-exclamation-triangle' : 'fa-clock';
// Calculate followers badge
const followers = lot.followersCount || 0;
const followersBadge = followers > 50 ? 'bg-red-100 text-red-800' :
followers > 20 ? 'bg-orange-100 text-orange-800' :
followers > 5 ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-600';
// Calculate estimate range
const estMin = lot.estimatedMin ? (lot.estimatedMin / 100).toFixed(0) : null;
const estMax = lot.estimatedMax ? (lot.estimatedMax / 100).toFixed(0) : null;
const estimateDisplay = estMin && estMax ? `${estMin}-${estMax}` : '--';
// Calculate total cost (including VAT and premium)
const currentBid = lot.currentBid || 0;
const vat = lot.vat || 0;
const premium = lot.buyerPremiumPercentage || 0;
const totalCost = currentBid * (1 + (vat/100) + (premium/100));
const totalCostDisplay = totalCost > 0 ? `${totalCost.toFixed(2)}` : '--';
// Bargain indicator
const isBargain = estMin && currentBid < parseFloat(estMin);
const bargainBadge = isBargain ? '<span class="ml-1 text-xs bg-green-500 text-white px-1 rounded">DEAL</span>' : '';
return ` return `
<tr class="hover:bg-gray-50 transition-colors"> <tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${lot.lotId || '--'}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${lot.lotId || '--'}</td>
<td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="${lot.title || 'N/A'}">${lot.title || 'N/A'}</td> <td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="${lot.title || 'N/A'}">${lot.title || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-600">${lot.currency || 'EUR'} ${lot.currentBid ? parseFloat(lot.currentBid).toFixed(2) : '0.00'}</td> <td class="px-6 py-4 whitespace-nowrap">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${lot.closingTime ? new Date(lot.closingTime).toLocaleString() : 'N/A'}</td> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${followersBadge}">
<i class="fas fa-eye mr-1"></i>${followers}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-600">${lot.currency || 'EUR'} ${lot.currentBid ? parseFloat(lot.currentBid).toFixed(2) : '0.00'}${bargainBadge}</td>
<td class="px-6 py-4 whitespace-nowrap text-xs text-gray-500">${estimateDisplay}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-purple-600" title="Including VAT (${vat}%) + Premium (${premium}%)">${totalCostDisplay}</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeColor}"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeColor}">
<i class="fas ${urgencyIcon} mr-1"></i>${minutesLeft} min <i class="fas ${urgencyIcon} mr-1"></i>${minutesLeft} min
@@ -1394,6 +1522,46 @@ window.addEventListener('offline', () => {
showToast('Connection lost - working offline', 'warning'); showToast('Connection lost - working offline', 'warning');
addLog('Network connection lost', 'warning'); addLog('Network connection lost', 'warning');
}); });
// Intelligence widget handlers
function showSleeperLots() {
if (!dashboardState.data.sleepers || dashboardState.data.sleepers.length === 0) {
showToast('No sleeper lots found', 'info');
return;
}
dashboardState.data.closingSoon = dashboardState.data.sleepers;
applyFilters();
showToast(`Showing ${dashboardState.data.sleepers.length} sleeper lots`, 'success');
addLog(`Filtered to sleeper lots (high interest, low bids)`);
// Scroll to table
document.getElementById('closing-soon-table').scrollIntoView({ behavior: 'smooth' });
}
function showBargainLots() {
if (!dashboardState.data.bargains || dashboardState.data.bargains.length === 0) {
showToast('No bargain lots found', 'info');
return;
}
dashboardState.data.closingSoon = dashboardState.data.bargains;
applyFilters();
showToast(`Showing ${dashboardState.data.bargains.length} bargain lots`, 'success');
addLog(`Filtered to bargain lots (below estimate)`);
// Scroll to table
document.getElementById('closing-soon-table').scrollIntoView({ behavior: 'smooth' });
}
function showPopularLots() {
if (!dashboardState.data.popular || dashboardState.data.popular.length === 0) {
showToast('No popular lots found', 'info');
return;
}
dashboardState.data.closingSoon = dashboardState.data.popular;
applyFilters();
showToast(`Showing ${dashboardState.data.popular.length} popular lots`, 'success');
addLog(`Filtered to popular lots (high followers)`);
// Scroll to table
document.getElementById('closing-soon-table').scrollIntoView({ behavior: 'smooth' });
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,990 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auctiora - Valuation Analytics</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/plotly.js/3.0.3/plotly.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--primary: #1e3a8a;
--secondary: #3b82f6;
--accent: #60a5fa;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
}
.gradient-bg {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--accent) 100%);
}
.formula-card {
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border-left: 4px solid var(--secondary);
transition: all 0.3s ease;
}
.formula-card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
.variable-badge {
background: linear-gradient(90deg, #dbeafe, #eff6ff);
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-family: 'Courier New', monospace;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.result-highlight {
background: linear-gradient(90deg, #d1fae5, #ecfdf5);
border: 2px solid var(--success);
border-radius: 12px;
padding: 20px;
font-family: 'Courier New', monospace;
}
.insight-alert {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.tooltip {
position: relative;
cursor: help;
border-bottom: 1px dotted #3b82f6;
}
.tooltip:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: #374151;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.75rem;
white-space: nowrap;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.math-display {
font-family: 'Times New Roman', serif;
font-size: 1.1rem;
line-height: 1.8;
background: #f9fafb;
border-radius: 8px;
padding: 16px;
margin: 12px 0;
border-left: 4px solid var(--secondary);
}
.sensitivity-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: #e5e7eb;
outline: none;
}
.sensitivity-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--secondary);
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="gradient-bg text-white py-6 shadow-lg">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/dashboard" class="text-white hover:text-blue-200 transition">
<i class="fas fa-arrow-left mr-2"></i>Back to Dashboard
</a>
<div>
<h1 class="text-3xl font-bold mb-1">
<i class="fas fa-calculator mr-3"></i>
Valuation Analytics Engine
</h1>
<p class="text-lg opacity-90">Mathematical Framework for Auction Intelligence</p>
</div>
</div>
<div class="text-right">
<button onclick="exportAnalysis()"
class="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition">
<i class="fas fa-download mr-2"></i>Export Analysis
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="container mx-auto px-4 py-8">
<!-- Input Parameters Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
<!-- Lot Parameter Inputs -->
<div class="lg:col-span-2 bg-white rounded-xl shadow-md p-6">
<h2 class="text-2xl font-semibold text-gray-800 mb-6 flex items-center">
<i class="fas fa-edit mr-2 text-blue-600"></i>
Input Parameters
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Basic Parameters -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-700 border-b pb-2">Current State</h3>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">P_current</span>
<span class="tooltip" data-tooltip="Current bid in EUR">Current Bid (€):</span>
</label>
<input type="number" id="p_current" value="7200"
class="w-32 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">C_target</span>
<span class="tooltip" data-tooltip="Condition score 0-10">Condition Score:</span>
</label>
<input type="range" id="c_target" min="0" max="10" step="0.1" value="7.5"
class="sensitivity-slider"
oninput="recalculate()">
<span id="c_target_display" class="ml-3 font-mono">7.5</span>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">T_target</span>
<span class="tooltip" data-tooltip="Manufacturing year">Year Made:</span>
</label>
<input type="number" id="t_target" value="2015"
class="w-32 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">W_watch</span>
<span class="tooltip" data-tooltip="Number of watchers">Watch Count:</span>
</label>
<input type="number" id="w_watch" value="87"
class="w-32 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
</div>
<!-- Market Context -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-700 border-b pb-2">Market Context</h3>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">σ_market</span>
<span class="tooltip" data-tooltip="Market volatility 0-1">Market Volatility:</span>
</label>
<input type="range" id="sigma_market" min="0" max="1" step="0.01" value="0.15"
class="sensitivity-slider"
oninput="recalculate()">
<span id="sigma_display" class="ml-3 font-mono">0.15</span>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">Λ_b</span>
<span class="tooltip" data-tooltip="Current bid velocity (bids/min)">Bid Velocity:</span>
</label>
<input type="number" id="lambda_b" value="2.3" step="0.1"
class="w-32 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">t_close</span>
<span class="tooltip" data-tooltip="Minutes until lot closes">Time to Close (min):</span>
</label>
<input type="number" id="t_close" value="45"
class="w-32 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<span class="variable-badge mr-2">N_docs</span>
<span class="tooltip" data-tooltip="Number of provenance documents">Provenance Docs:</span>
</label>
<input type="number" id="n_docs" value="2" min="0"
class="w-32 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
</div>
</div>
<!-- Estimate Range Inputs -->
<div class="mt-6 pt-6 border-t">
<h3 class="text-lg font-semibold text-gray-700 mb-3">Auction Estimates</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">Estimated Min (€)</label>
<input type="number" id="est_min" value="6000"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">Estimated Max (€)</label>
<input type="number" id="est_max" value="9000"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
oninput="recalculate()">
</div>
</div>
</div>
</div>
<!-- Real-time Results -->
<div class="space-y-6">
<!-- FMV Result -->
<div class="result-highlight">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-700">Fair Market Value (FMV)</span>
<i class="fas fa-chart-line text-green-600"></i>
</div>
<div class="text-3xl font-bold text-green-600" id="fmv_result">€8,500.00</div>
<div class="text-xs text-green-700 mt-1" id="fmv_confidence">87% confidence</div>
</div>
<!-- Undervaluation Score -->
<div class="result-highlight">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-700">Undervaluation Score</span>
<i class="fas fa-gem text-purple-600"></i>
</div>
<div class="text-3xl font-bold" id="u_score_result">0.153</div>
<div class="text-xs mt-1" id="u_interpretation">
<span class="text-green-600">15.3% below fair value</span>
</div>
</div>
<!-- Predicted Final -->
<div class="result-highlight">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-700">Predicted Final Price</span>
<i class="fas fa-crystal-ball text-blue-600"></i>
</div>
<div class="text-3xl font-bold text-blue-600" id="p_final_result">€8,900.00</div>
<div class="text-xs text-blue-700 mt-1" id="prediction_range">
95% CI: €8,200 - €9,600
</div>
</div>
</div>
</div>
<!-- Formula Explanations -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- FMV Formula -->
<div class="formula-card rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-function mr-2 text-blue-600"></i>
1. Fair Market Value (FMV)
</h3>
<div class="math-display">
FMV = <strong>Σ(P<sub>i</sub> · ω<sub>c</sub> · ω<sub>t</sub> · ω<sub>p</sub> · ω<sub>h</sub>) / Σ(ω<sub>c</sub> · ω<sub>t</sub> · ω<sub>p</sub> · ω<sub>h</sub>)</strong>
</div>
<div class="text-sm text-gray-600 space-y-2 mt-4">
<p><strong>Explanation:</strong> Weighted average of comparable sales where each comparable is weighted by:</p>
<ul class="list-disc list-inside ml-4 space-y-1">
<li><span class="variable-badge">ω_c</span> Condition similarity (exponential decay)</li>
<li><span class="variable-badge">ω_t</span> Age proximity (exponential decay)</li>
<li><span class="variable-badge">ω_p</span> Provenance premium (linear boost)</li>
<li><span class="variable-badge">ω_h</span> Historical relevance (logistic function)</li>
</ul>
</div>
</div>
<!-- Undervaluation Score -->
<div class="formula-card rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-search-dollar mr-2 text-purple-600"></i>
2. Undervaluation Detection
</h3>
<div class="math-display">
U<sub>score</sub> = <strong>(FMV - P<sub>current</sub>)/FMV · σ<sub>market</sub> · (1 + B<sub>velocity</sub>/10) · ln(1 + W<sub>watch</sub>/W<sub>bid</sub>)</strong>
</div>
<div class="text-sm text-gray-600 space-y-2 mt-4">
<p><strong>Explanation:</strong> Quantifies mispricing opportunity factoring:</p>
<ul class="list-disc list-inside ml-4 space-y-1">
<li>Price gap percentage</li>
<li>Market volatility multiplier</li>
<li>Bid velocity acceleration</li>
<li>Watch-to-bid ratio (buyer intent)</li>
</ul>
<p class="mt-2 p-2 bg-yellow-50 rounded"><strong>Alert threshold:</strong> U<sub>score</sub> > 0.25</p>
</div>
</div>
<!-- Predicted Final Price -->
<div class="formula-card rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-chart-line mr-2 text-green-600"></i>
3. Final Price Prediction
</h3>
<div class="math-display">
<sub>final</sub> = <strong>FMV · (1 + ε<sub>bid</sub> + ε<sub>time</sub> + ε<sub>comp</sub>)</strong>
</div>
<div class="text-sm text-gray-600 space-y-2 mt-4">
<p><strong>Explanation:</strong> Adjusts FMV for auction dynamics:</p>
<ul class="list-disc list-inside ml-4 space-y-1">
<li><span class="variable-badge">ε_bid</span> Bid momentum (tanh function)</li>
<li><span class="variable-badge">ε_time</span> Time pressure (exponential decay)</li>
<li><span class="variable-badge">ε_comp</span> Competition level (logarithmic)</li>
</ul>
</div>
</div>
<!-- Condition Multiplier -->
<div class="formula-card rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-heartbeat mr-2 text-red-600"></i>
4. Condition Multiplier
</h3>
<div class="math-display">
M<sub>cond</sub> = <strong>exp(α<sub>c</sub> · √C<sub>target</sub> - β<sub>c</sub>)</strong>
</div>
<div class="text-sm text-gray-600 space-y-2 mt-4">
<p><strong>Explanation:</strong> Normalizes prices across condition states using square-root scaling:</p>
<ul class="list-disc list-inside ml-4 space-y-1">
<li>C = 10 (mint): <strong>1.48x</strong> premium</li>
<li>C = 7.5 (good): <strong>1.12x</strong> premium</li>
<li>C = 5 (avg): <strong>0.91x</strong> discount</li>
</ul>
</div>
</div>
</div>
<!-- Strategy Recommendations -->
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<h2 class="text-2xl font-semibold text-gray-800 mb-6 flex items-center">
<i class="fas fa-chess mr-2 text-blue-600"></i>
Bidding Strategy Recommendations
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center p-6 bg-gradient-to-br from-green-50 to-green-100 rounded-xl">
<div class="text-3xl font-bold text-green-600 mb-2" id="strategy_max">€9,200</div>
<div class="text-sm text-green-700">Recommended Max Bid</div>
<div class="text-xs text-green-600 mt-2" id="max_strategy_type">Standard strategy</div>
</div>
<div class="text-center p-6 bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl">
<div class="text-3xl font-bold text-blue-600 mb-2" id="optimal_timing">10 min</div>
<div class="text-sm text-blue-700">Optimal Bid Timing</div>
<div class="text-xs text-blue-600 mt-2">Before close</div>
</div>
<div class="text-center p-6 bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl">
<div class="text-3xl font-bold text-purple-600 mb-2" id="competition_level">Medium</div>
<div class="text-sm text-purple-700">Competition Level</div>
<div class="text-xs text-purple-600 mt-2" id="competition_details">2.3 bids/min</div>
</div>
</div>
<!-- Strategy Details -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
<div id="strategy_details" class="space-y-3">
<div class="flex items-start space-x-3">
<i class="fas fa-info-circle text-blue-500 mt-1"></i>
<div>
<strong>Analysis:</strong> <span id="strategy_analysis">Bid velocity is moderate (2.3 bids/min) with high watch count indicating potential sniping.</span>
</div>
</div>
<div class="flex items-start space-x-3">
<i class="fas fa-lightbulb text-yellow-500 mt-1"></i>
<div>
<strong>Recommendation:</strong> <span id="strategy_recommendation">Wait until final 10 minutes. Set max bid at €9,200 (7% above FMV) to secure lot while avoiding bidding war.</span>
</div>
</div>
<div class="flex items-start space-x-3">
<i class="fas fa-exclamation-triangle text-red-500 mt-1"></i>
<div>
<strong>Risk Factors:</strong> <span id="strategy_risks">High watch-to-bid ratio (87:8) suggests aggressive sniping likely. Reserve may be close to current bid.</span>
</div>
</div>
</div>
</div>
</div>
<!-- Sensitivity Analysis -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- Condition Sensitivity -->
<div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-sliders-h mr-2 text-blue-600"></i>
Condition Sensitivity
</h3>
<div id="condition-chart" style="height: 300px;"></div>
<div class="text-sm text-gray-600 mt-3">
<p>How FMV changes with condition score. Current: <strong id="current_condition_point">7.5</strong></p>
</div>
</div>
<!-- Time Sensitivity -->
<div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-clock mr-2 text-green-600"></i>
Time Pressure Impact
</h3>
<div id="time-chart" style="height: 300px;"></div>
<div class="text-sm text-gray-600 mt-3">
<p>Final price prediction vs. time to close. Current: <strong id="current_time_point">45 min</strong></p>
</div>
</div>
</div>
<!-- Activity Log -->
<div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-history mr-2 text-purple-600"></i>
Calculation Log
</h3>
<div id="calculation-log" class="space-y-2 max-h-64 overflow-y-auto border rounded-lg p-3 bg-gray-50">
<div class="text-sm text-gray-500 flex items-center">
<i class="fas fa-info-circle mr-2 text-blue-500"></i>
<span>System initialized. Ready for calculations...</span>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-6 mt-12">
<div class="container mx-auto px-4 text-center">
<p class="text-gray-300">
<i class="fas fa-calculator mr-2"></i>
Auctiora Valuation Engine v2.1 | Mathematical Framework
</p>
<p class="text-sm text-gray-400 mt-2">
Multi-factor weighted valuation with exponential decay models
</p>
</div>
</footer>
<script>
// Mathematical constants (retained for sensitivity charts and fallback)
const CONSTANTS = {
lambda_c: 0.693, // Condition decay constant
lambda_t: 0.048, // Time decay constant
delta_p: 0.15, // Provenance premium coefficient
kh: 0.01, // Historical relevance coefficient
alpha_c: 0.15, // Condition sensitivity
beta_c: 0.40, // Condition baseline offset
gamma: 0.25, // Depreciation aggressivity
b_threshold: 10, // High velocity threshold
phi_1: 0.15, // Bid momentum coefficient 1
phi_2: 0.10, // Bid momentum coefficient 2
psi: 0.20, // Time pressure coefficient
rho: 0.08, // Competition coefficient
theta_agg: 0.10, // Aggressive discount target
theta_cons: 0.05, // Conservative overbid tolerance
delta_margin: 50 // Minimum margin €
};
// Dashboard state
let dashboardState = {
data: {},
filters: {},
autoRefresh: true,
refreshInterval: 15000,
isCalculating: false,
apiAvailable: false
};
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
addLog('Initializing valuation engine...');
checkAPIHealth();
setupEventListeners();
});
// Check API availability on load
async function checkAPIHealth() {
try {
const response = await fetch('/api/analytics/valuation/health', {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (response.ok) {
dashboardState.apiAvailable = true;
addLog('API connection established', 'success');
showToast('Connected to valuation engine', 'success');
} else {
dashboardState.apiAvailable = false;
addLog('API health check failed - running in offline mode', 'warning');
showToast('Running in offline mode', 'warning');
}
} catch (error) {
dashboardState.apiAvailable = false;
addLog('API unavailable - using fallback calculations', 'warning');
showToast('Offline mode: using fallback calculations', 'warning');
} finally {
recalculate();
}
}
// Setup event listeners
function setupEventListeners() {
// Input change listeners
document.getElementById('c_target').addEventListener('input', function() {
document.getElementById('c_target_display').textContent = this.value;
recalculate();
});
document.getElementById('sigma_market').addEventListener('input', function() {
document.getElementById('sigma_display').textContent = this.value;
recalculate();
});
// Add listeners to all input fields
const inputs = document.querySelectorAll('input[type="number"]');
inputs.forEach(input => {
input.addEventListener('input', debounce(recalculate, 300));
});
}
// Debounce function to prevent excessive API calls
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Main recalculation function - calls backend API
async function recalculate() {
if (dashboardState.isCalculating) return;
const params = getInputParams();
showLoadingState(true);
if (dashboardState.apiAvailable) {
try {
const response = await fetch('/api/analytics/valuation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(params)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const valuationData = await response.json();
dashboardState.data.valuation = valuationData;
updateAllDisplays(valuationData);
addLog(`API calculation completed in ${valuationData.calculationTimeMs}ms`, 'success');
showToast('Valuation updated from server', 'success');
} catch (error) {
console.error('Valuation API error:', error);
addLog(`API error: ${error.message} - falling back to mock calculation`, 'error');
showToast(`API failed: ${error.message}`, 'error');
await calculateMockFallback(params);
}
} else {
// API not available, use fallback directly
await calculateMockFallback(params);
}
showLoadingState(false);
}
// Fallback mock calculation (original logic)
async function calculateMockFallback(params) {
addLog('Using fallback calculation...', 'info');
const comparables = [
{ price: 8200, condition: 8, year: 2016, provenance: 1, days_ago: 30 },
{ price: 7800, condition: 7, year: 2014, provenance: 0, days_ago: 45 },
{ price: 8500, condition: 9, year: 2017, provenance: 1, days_ago: 60 },
{ price: 7500, condition: 6, year: 2013, provenance: 0, days_ago: 25 }
];
let weightedSum = 0;
let weightSum = 0;
comparables.forEach(comp => {
const wc = Math.exp(-CONSTANTS.lambda_c * Math.abs(params.c_target - comp.condition));
const wt = Math.exp(-CONSTANTS.lambda_t * Math.abs(params.t_target - comp.year));
const wp = 1 + CONSTANTS.delta_p * (params.n_docs > 0 ? 1 : 0 - comp.provenance);
const wh = 1 / (1 + Math.exp(-CONSTANTS.kh * (comp.days_ago - 45)));
const weight = wc * wt * wp * wh;
weightedSum += comp.price * weight;
weightSum += weight;
});
let fmv = weightSum > 0 ? weightedSum / weightSum : (params.est_min + params.est_max) / 2;
const mCond = Math.exp(CONSTANTS.alpha_c * Math.sqrt(params.c_target) - CONSTANTS.beta_c);
fmv *= mCond;
if (params.n_docs > 0) {
const provPremium = CONSTANTS.delta_p + 0.05 * Math.log(1 + params.n_docs);
fmv *= (1 + provPremium);
}
// Create mock response structure matching API format
const mockResponse = {
fairMarketValue: {
value: Math.round(fmv * 100) / 100,
conditionMultiplier: Math.round(mCond * 1000) / 1000,
provenancePremium: params.n_docs > 0 ? CONSTANTS.delta_p + 0.05 * Math.log(1 + params.n_docs) : 0,
comparablesUsed: comparables.length,
confidence: 0.85,
weightedComparables: comparables.map(comp => ({
comparableLotId: `MOCK-${comp.price}`,
finalPrice: comp.price,
totalWeight: 0.25,
components: {
conditionWeight: Math.round(Math.exp(-CONSTANTS.lambda_c * Math.abs(params.c_target - comp.condition)) * 1000) / 1000,
timeWeight: Math.round(Math.exp(-CONSTANTS.lambda_t * Math.abs(params.t_target - comp.year)) * 1000) / 1000,
provenanceWeight: Math.round((1 + CONSTANTS.delta_p * (params.n_docs > 0 ? 1 : 0 - comp.provenance)) * 1000) / 1000,
historicalWeight: Math.round((1 / (1 + Math.exp(-CONSTANTS.kh * (comp.days_ago - 45)))) * 1000) / 1000
}
}))
},
undervaluationScore: calculateUndervaluationScore(params, fmv),
pricePrediction: calculateFinalPrice(params, fmv),
biddingStrategy: generateBiddingStrategy(params, {value: fmv}, calculateFinalPrice(params, fmv)),
calculationTimeMs: 150
};
updateAllDisplays(mockResponse);
showToast('Using fallback calculations', 'warning');
}
// Update all displays with unified response format
function updateAllDisplays(data) {
updateFMVDisplay(data.fairMarketValue);
updateUndervaluationDisplay(data.undervaluationScore);
updateFinalPriceDisplay(data.pricePrediction);
updateStrategyDisplay(data.biddingStrategy);
}
// Show/hide loading state
function showLoadingState(isLoading) {
dashboardState.isCalculating = isLoading;
const inputs = document.querySelectorAll('input[type="number"], input[type="range"], select, button:not(#auto-refresh-btn)');
inputs.forEach(el => el.disabled = isLoading);
// Show loading indicators
const loadingHTML = '<i class="fas fa-spinner fa-spin"></i>';
const elements = ['fmv_result', 'u_score_result', 'p_final_result'];
elements.forEach(id => {
const el = document.getElementById(id);
if (isLoading) {
el.innerHTML = loadingHTML;
el.classList.add('text-blue-600');
} else {
el.classList.remove('text-blue-600');
}
});
}
// Get input parameters
function getInputParams() {
return {
lotId: document.getElementById('lot-id')?.value || 'DEMO-LOT-' + Date.now(),
currentBid: parseFloat(document.getElementById('p_current').value) || 0,
conditionScore: parseFloat(document.getElementById('c_target').value) || 0,
manufacturingYear: parseInt(document.getElementById('t_target').value) || 0,
watchCount: parseInt(document.getElementById('w_watch').value) || 0,
bidCount: parseInt(document.getElementById('w_bid')?.value) || 8,
marketVolatility: parseFloat(document.getElementById('sigma_market').value) || 0.15,
bidVelocity: parseFloat(document.getElementById('lambda_b').value) || 0,
minutesUntilClose: parseInt(document.getElementById('t_close').value) || 0,
provenanceDocs: parseInt(document.getElementById('n_docs').value) || 0,
estimatedMin: parseFloat(document.getElementById('est_min').value) || 0,
estimatedMax: parseFloat(document.getElementById('est_max').value) || 0
};
}
// Update displays (adapted for API response format)
function updateFMVDisplay(fmvData) {
document.getElementById('fmv_result').textContent = `${fmvData.value.toFixed(2)}`;
document.getElementById('fmv_confidence').textContent = `${Math.round(fmvData.confidence * 100)}% confidence`;
if (fmvData.comparablesUsed) {
addLog(`Used ${fmvData.comparablesUsed} comparables for FMV calculation`);
}
}
function updateUndervaluationDisplay(uScore) {
document.getElementById('u_score_result').textContent = uScore.toFixed(3);
const interp = uScore > 0.25 ? 'text-green-600' :
uScore > 0.15 ? 'text-yellow-600' : 'text-red-600';
const text = uScore > 0.25 ? `${(uScore*100).toFixed(1)}% below fair value - STRONG BUY` :
uScore > 0.15 ? `${(uScore*100).toFixed(1)}% below fair value - CONSIDER` :
'Fairly valued or overpriced';
document.getElementById('u_interpretation').innerHTML = `<span class="${interp}">${text}</span>`;
}
function updateFinalPriceDisplay(predictionData) {
document.getElementById('p_final_result').textContent = `${predictionData.predictedPrice.toFixed(2)}`;
document.getElementById('prediction_range').textContent =
`95% CI: €${predictionData.confidenceIntervalLower.toFixed(0)} - €${predictionData.confidenceIntervalUpper.toFixed(0)}`;
if (predictionData.components) {
const {bidMomentum, timePressure, competition} = predictionData.components;
addLog(`Price prediction components: bid=${bidMomentum}, time=${timePressure}, comp=${competition}`);
}
}
function updateStrategyDisplay(strategyData) {
document.getElementById('strategy_max').textContent = `${strategyData.maxBid.toFixed(0)}`;
document.getElementById('optimal_timing').textContent =
strategyData.recommendedTimingText || strategyData.recommendedTiming?.replace('_', ' ') || 'FINAL 10 MINUTES';
document.getElementById('competition_level').textContent = strategyData.competitionLevel || 'MEDIUM';
document.getElementById('competition_details').textContent =
`${strategyData.type ? strategyData.type.replace('_', ' ') : 'Standard'} strategy`;
if (strategyData.analysis) {
document.getElementById('strategy_analysis').textContent = strategyData.analysis;
}
if (strategyData.riskFactors) {
document.getElementById('strategy_risks').textContent = strategyData.riskFactors.join(', ');
}
}
// Generate sensitivity charts (using mock calculations)
function generateSensitivityCharts() {
// Condition sensitivity chart
const conditionRange = Array.from({length: 21}, (_, i) => i * 0.5);
const conditionValues = conditionRange.map(c => {
const params = getInputParams();
params.c_target = c;
return calculateMockFMV(params); // Use simplified mock for chart
});
Plotly.newPlot('condition-chart', [{
x: conditionRange,
y: conditionValues,
type: 'scatter',
mode: 'lines+markers',
line: { color: '#3b82f6', width: 3 },
marker: { size: 6 },
name: 'FMV vs Condition'
}], {
xaxis: { title: 'Condition Score (C)' },
yaxis: { title: 'FMV (€)' },
margin: { t: 20, b: 40, l: 60, r: 20 },
font: { size: 12 }
}, {responsive: true});
// Time sensitivity chart
const timeRange = Array.from({length: 20}, (_, i) => i * 5);
const timeValues = timeRange.map(t => {
const params = getInputParams();
params.t_close = t;
return calculateMockFinalPrice(params, calculateMockFMV(params));
});
Plotly.newPlot('time-chart', [{
x: timeRange,
y: timeValues,
type: 'scatter',
mode: 'lines+markers',
line: { color: '#10b981', width: 3 },
marker: { size: 6 },
name: 'Predicted Final vs Time'
}], {
xaxis: { title: 'Time to Close (minutes)' },
yaxis: { title: 'Predicted Final Price (€)' },
margin: { t: 20, b: 40, l: 60, r: 20 },
font: { size: 12 }
}, {responsive: true});
}
// Simplified mock FMV for chart generation
function calculateMockFMV(params) {
const comparables = [
{ price: 8200, condition: 8, year: 2016, provenance: 1, days_ago: 30 },
{ price: 7800, condition: 7, year: 2014, provenance: 0, days_ago: 45 }
];
let weightedSum = 0;
let weightSum = 0;
comparables.forEach(comp => {
const wc = Math.exp(-CONSTANTS.lambda_c * Math.abs(params.c_target - comp.condition));
const wt = Math.exp(-CONSTANTS.lambda_t * Math.abs(params.t_target - comp.year));
const weight = wc * wt;
weightedSum += comp.price * weight;
weightSum += weight;
});
let fmv = weightSum > 0 ? weightedSum / weightSum : 8000;
const mCond = Math.exp(CONSTANTS.alpha_c * Math.sqrt(params.c_target) - CONSTANTS.beta_c);
return fmv * mCond;
}
// Simplified mock final price for chart generation
function calculateMockFinalPrice(params, fmv) {
const epsilon_bid = Math.tanh(CONSTANTS.phi_1 * params.lambda_b);
const epsilon_time = CONSTANTS.psi * Math.exp(-params.t_close / 30);
return fmv * (1 + epsilon_bid + epsilon_time);
}
// Activity log
function addLog(message, type = 'info') {
const log = document.getElementById('calculation-log');
const entry = document.createElement('div');
const iconMap = {
success: 'fa-check-circle text-green-600',
error: 'fa-exclamation-circle text-red-600',
warning: 'fa-exclamation-triangle text-yellow-600',
info: 'fa-info-circle text-blue-600'
};
entry.className = 'text-sm text-gray-700 flex items-center space-x-2';
entry.innerHTML = `
<i class="fas ${iconMap[type]} mt-1 text-xs"></i>
<span class="text-gray-400 text-xs">${new Date().toLocaleTimeString()}</span>
<span>${message}</span>
`;
log.insertBefore(entry, log.firstChild);
while (log.children.length > 20) log.removeChild(log.lastChild);
}
// Toast notification system
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container') || createToastContainer();
const toast = document.createElement('div');
const iconMap = {
success: 'fa-check-circle',
error: 'fa-exclamation-circle',
warning: 'fa-exclamation-triangle',
info: 'fa-info-circle'
};
toast.className = `toast ${type}`;
toast.innerHTML = `
<div class="flex items-center space-x-3">
<i class="fas ${iconMap[type]} text-lg"></i>
<span class="font-medium">${message}</span>
<button onclick="this.parentElement.parentElement.remove()"
class="ml-4 text-white/80 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
`;
container.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, duration);
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed top-4 right-4 z-50 space-y-2';
document.body.appendChild(container);
return container;
}
// Export analysis function (uses API data if available)
function exportAnalysis() {
if (!dashboardState.data.valuation) {
showToast('No valuation data to export', 'warning');
return;
}
const exportData = {
...dashboardState.data.valuation,
exportedAt: new Date().toISOString(),
version: '2.1',
source: dashboardState.apiAvailable ? 'API' : 'FALLBACK'
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `valuation-${exportData.lotId || 'analysis'}-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addLog('Analysis exported successfully', 'success');
showToast('Analysis exported', 'success');
}
// Style for toasts (add to HTML <style> section)
const toastStyles = document.createElement('style');
toastStyles.textContent = `
.toast {
transform: translateX(400px);
transition: transform 0.3s ease;
max-width: 400px;
padding: 16px 24px;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
color: white;
}
.toast.show { transform: translateX(0); }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
.toast.warning { background: #f59e0b; }
.toast.info { background: #3b82f6; }
`;
document.head.appendChild(toastStyles);
</script>
</body>
</html>

View File

@@ -143,7 +143,7 @@ class DatabaseServiceTest {
@Test @Test
@DisplayName("Should insert and retrieve lot") @DisplayName("Should insert and retrieve lot")
void testUpsertAndGetLot() throws SQLException { void testUpsertAndGetLot() throws SQLException {
var lot = new Lot( var lot = Lot.basic(
12345, // saleId 12345, // saleId
67890, // lotId 67890, // lotId
"Forklift", "Forklift",
@@ -180,7 +180,7 @@ class DatabaseServiceTest {
@Test @Test
@DisplayName("Should update lot current bid") @DisplayName("Should update lot current bid")
void testUpdateLotCurrentBid() throws SQLException { void testUpdateLotCurrentBid() throws SQLException {
var lot = new Lot( var lot = Lot.basic(
11111, 22222, "Test Item", "Description", "", "", 0, "Category", 11111, 22222, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/22222", null, false 100.00, "EUR", "https://example.com/lot/22222", null, false
); );
@@ -188,7 +188,7 @@ class DatabaseServiceTest {
db.upsertLot(lot); db.upsertLot(lot);
// Update bid // Update bid
var updatedLot = new Lot( var updatedLot = Lot.basic(
11111, 22222, "Test Item", "Description", "", "", 0, "Category", 11111, 22222, "Test Item", "Description", "", "", 0, "Category",
250.00, "EUR", "https://example.com/lot/22222", null, false 250.00, "EUR", "https://example.com/lot/22222", null, false
); );
@@ -208,7 +208,7 @@ class DatabaseServiceTest {
@Test @Test
@DisplayName("Should update lot notification flags") @DisplayName("Should update lot notification flags")
void testUpdateLotNotificationFlags() throws SQLException { void testUpdateLotNotificationFlags() throws SQLException {
var lot = new Lot( var lot = Lot.basic(
33333, 44444, "Test Item", "Description", "", "", 0, "Category", 33333, 44444, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/44444", null, false 100.00, "EUR", "https://example.com/lot/44444", null, false
); );
@@ -216,7 +216,7 @@ class DatabaseServiceTest {
db.upsertLot(lot); db.upsertLot(lot);
// Update notification flag // Update notification flag
var updatedLot = new Lot( var updatedLot = Lot.basic(
33333, 44444, "Test Item", "Description", "", "", 0, "Category", 33333, 44444, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/44444", null, true 100.00, "EUR", "https://example.com/lot/44444", null, true
); );
@@ -237,7 +237,7 @@ class DatabaseServiceTest {
@DisplayName("Should insert and retrieve image records") @DisplayName("Should insert and retrieve image records")
void testInsertAndGetImages() throws SQLException { void testInsertAndGetImages() throws SQLException {
// First create a lot // First create a lot
var lot = new Lot( var lot = Lot.basic(
55555, 66666, "Test Lot", "Description", "", "", 0, "Category", 55555, 66666, "Test Lot", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/66666", null, false 100.00, "EUR", "https://example.com/lot/66666", null, false
); );
@@ -268,7 +268,7 @@ class DatabaseServiceTest {
int initialCount = db.getImageCount(); int initialCount = db.getImageCount();
// Add a lot and image // Add a lot and image
var lot = new Lot( var lot = Lot.basic(
77777, 88888, "Test Lot", "Description", "", "", 0, "Category", 77777, 88888, "Test Lot", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/88888", null, false 100.00, "EUR", "https://example.com/lot/88888", null, false
); );
@@ -304,7 +304,7 @@ class DatabaseServiceTest {
@Test @Test
@DisplayName("Should handle lots with null closing time") @DisplayName("Should handle lots with null closing time")
void testLotWithNullClosingTime() throws SQLException { void testLotWithNullClosingTime() throws SQLException {
var lot = new Lot( var lot = Lot.basic(
98765, 12340, "Test Item", "Description", "", "", 0, "Category", 98765, 12340, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/12340", null, false 100.00, "EUR", "https://example.com/lot/12340", null, false
); );
@@ -323,7 +323,7 @@ class DatabaseServiceTest {
@Test @Test
@DisplayName("Should retrieve active lots only") @DisplayName("Should retrieve active lots only")
void testGetActiveLots() throws SQLException { void testGetActiveLots() throws SQLException {
var activeLot = new Lot( var activeLot = Lot.basic(
11111, 55551, "Active Lot", "Description", "", "", 0, "Category", 11111, 55551, "Active Lot", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/55551", 100.00, "EUR", "https://example.com/lot/55551",
LocalDateTime.now().plusDays(1), false LocalDateTime.now().plusDays(1), false
@@ -345,7 +345,7 @@ class DatabaseServiceTest {
Thread t1 = new Thread(() -> { Thread t1 = new Thread(() -> {
try { try {
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
db.upsertLot(new Lot( db.upsertLot(Lot.basic(
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat", 99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
100.0, "EUR", "https://example.com/" + i, null, false 100.0, "EUR", "https://example.com/" + i, null, false
)); ));
@@ -358,7 +358,7 @@ class DatabaseServiceTest {
Thread t2 = new Thread(() -> { Thread t2 = new Thread(() -> {
try { try {
for (int i = 10; i < 20; i++) { for (int i = 10; i < 20; i++) {
db.upsertLot(new Lot( db.upsertLot(Lot.basic(
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat", 99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
200.0, "EUR", "https://example.com/" + i, null, false 200.0, "EUR", "https://example.com/" + i, null, false
)); ));

View File

@@ -84,7 +84,7 @@ class IntegrationTest {
db.upsertAuction(auction); db.upsertAuction(auction);
// Step 2: Import lots for this auction // Step 2: Import lots for this auction
var lot1 = new Lot( var lot1 = Lot.basic(
12345, 10001, 12345, 10001,
"Toyota Forklift 2.5T", "Toyota Forklift 2.5T",
"Electric forklift in excellent condition", "Electric forklift in excellent condition",
@@ -99,7 +99,7 @@ class IntegrationTest {
false false
); );
var lot2 = new Lot( var lot2 = Lot.basic(
12345, 10002, 12345, 10002,
"Office Furniture Set", "Office Furniture Set",
"Desks, chairs, and cabinets", "Desks, chairs, and cabinets",
@@ -159,7 +159,7 @@ class IntegrationTest {
.orElseThrow(); .orElseThrow();
// Update bid // Update bid
var updatedLot = new Lot( var updatedLot = Lot.basic(
lot.saleId(), lot.lotId(), lot.title(), lot.description(), lot.saleId(), lot.lotId(), lot.title(), lot.description(),
lot.manufacturer(), lot.type(), lot.year(), lot.category(), lot.manufacturer(), lot.type(), lot.year(), lot.category(),
2000.00, // Increased from 1500.00 2000.00, // Increased from 1500.00
@@ -190,7 +190,7 @@ class IntegrationTest {
@DisplayName("Integration: Closing alert workflow") @DisplayName("Integration: Closing alert workflow")
void testClosingAlertWorkflow() throws SQLException { void testClosingAlertWorkflow() throws SQLException {
// Create lot closing soon // Create lot closing soon
var closingSoon = new Lot( var closingSoon = Lot.basic(
12345, 20001, 12345, 20001,
"Closing Soon Item", "Closing Soon Item",
"Description", "Description",
@@ -217,7 +217,7 @@ class IntegrationTest {
); );
// Mark as notified // Mark as notified
var notified = new Lot( var notified = Lot.basic(
closingSoon.saleId(), closingSoon.lotId(), closingSoon.title(), closingSoon.saleId(), closingSoon.lotId(), closingSoon.title(),
closingSoon.description(), closingSoon.manufacturer(), closingSoon.type(), closingSoon.description(), closingSoon.manufacturer(), closingSoon.type(),
closingSoon.year(), closingSoon.category(), closingSoon.currentBid(), closingSoon.year(), closingSoon.category(), closingSoon.currentBid(),
@@ -310,7 +310,7 @@ class IntegrationTest {
@DisplayName("Integration: Object detection value estimation workflow") @DisplayName("Integration: Object detection value estimation workflow")
void testValueEstimationWorkflow() throws SQLException { void testValueEstimationWorkflow() throws SQLException {
// Create lot with detected objects // Create lot with detected objects
var lot = new Lot( var lot = Lot.basic(
40000, 50000, 40000, 50000,
"Construction Equipment", "Construction Equipment",
"Heavy machinery for construction", "Heavy machinery for construction",
@@ -378,7 +378,7 @@ class IntegrationTest {
var lotThread = new Thread(() -> { var lotThread = new Thread(() -> {
try { try {
for (var i = 0; i < 10; i++) { for (var i = 0; i < 10; i++) {
db.upsertLot(new Lot( db.upsertLot(Lot.basic(
60000 + i, 70000 + i, "Concurrent Lot " + i, "Desc", "", "", 0, "Cat", 60000 + i, 70000 + i, "Concurrent Lot " + i, "Desc", "", "", 0, "Cat",
100.0 * i, "EUR", "https://example.com/70" + i, null, false 100.0 * i, "EUR", "https://example.com/70" + i, null, false
)); ));

View File

@@ -73,7 +73,7 @@ class TroostwijkMonitorTest {
@DisplayName("Should track lots in database") @DisplayName("Should track lots in database")
void testLotTracking() throws SQLException { void testLotTracking() throws SQLException {
// Insert test lot // Insert test lot
var lot = new Lot( var lot = Lot.basic(
11111, 22222, 11111, 22222,
"Test Forklift", "Test Forklift",
"Electric forklift in good condition", "Electric forklift in good condition",
@@ -98,7 +98,7 @@ class TroostwijkMonitorTest {
@DisplayName("Should monitor lots closing soon") @DisplayName("Should monitor lots closing soon")
void testClosingSoonMonitoring() throws SQLException { void testClosingSoonMonitoring() throws SQLException {
// Insert lot closing in 4 minutes // Insert lot closing in 4 minutes
var closingSoon = new Lot( var closingSoon = Lot.basic(
33333, 44444, 33333, 44444,
"Closing Soon Item", "Closing Soon Item",
"Description", "Description",
@@ -128,7 +128,7 @@ class TroostwijkMonitorTest {
@Test @Test
@DisplayName("Should identify lots with time remaining") @DisplayName("Should identify lots with time remaining")
void testTimeRemainingCalculation() throws SQLException { void testTimeRemainingCalculation() throws SQLException {
var futureLot = new Lot( var futureLot = Lot.basic(
55555, 66666, 55555, 66666,
"Future Lot", "Future Lot",
"Description", "Description",
@@ -158,7 +158,7 @@ class TroostwijkMonitorTest {
@Test @Test
@DisplayName("Should handle lots without closing time") @DisplayName("Should handle lots without closing time")
void testLotsWithoutClosingTime() throws SQLException { void testLotsWithoutClosingTime() throws SQLException {
var noClosing = new Lot( var noClosing = Lot.basic(
77777, 88888, 77777, 88888,
"No Closing Time", "No Closing Time",
"Description", "Description",
@@ -188,7 +188,7 @@ class TroostwijkMonitorTest {
@Test @Test
@DisplayName("Should track notification status") @DisplayName("Should track notification status")
void testNotificationStatusTracking() throws SQLException { void testNotificationStatusTracking() throws SQLException {
var lot = new Lot( var lot = Lot.basic(
99999, 11110, 99999, 11110,
"Test Notification", "Test Notification",
"Description", "Description",
@@ -206,7 +206,7 @@ class TroostwijkMonitorTest {
monitor.getDb().upsertLot(lot); monitor.getDb().upsertLot(lot);
// Update notification flag // Update notification flag
var notified = new Lot( var notified = Lot.basic(
99999, 11110, 99999, 11110,
"Test Notification", "Test Notification",
"Description", "Description",
@@ -236,7 +236,7 @@ class TroostwijkMonitorTest {
@Test @Test
@DisplayName("Should update bid amounts") @DisplayName("Should update bid amounts")
void testBidAmountUpdates() throws SQLException { void testBidAmountUpdates() throws SQLException {
var lot = new Lot( var lot = Lot.basic(
12121, 13131, 12121, 13131,
"Bid Update Test", "Bid Update Test",
"Description", "Description",
@@ -254,7 +254,7 @@ class TroostwijkMonitorTest {
monitor.getDb().upsertLot(lot); monitor.getDb().upsertLot(lot);
// Simulate bid increase // Simulate bid increase
var higherBid = new Lot( var higherBid = Lot.basic(
12121, 13131, 12121, 13131,
"Bid Update Test", "Bid Update Test",
"Description", "Description",
@@ -287,7 +287,7 @@ class TroostwijkMonitorTest {
Thread t1 = new Thread(() -> { Thread t1 = new Thread(() -> {
try { try {
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
monitor.getDb().upsertLot(new Lot( monitor.getDb().upsertLot(Lot.basic(
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat", 20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
100.0, "EUR", "https://example.com/" + i, null, false 100.0, "EUR", "https://example.com/" + i, null, false
)); ));
@@ -300,7 +300,7 @@ class TroostwijkMonitorTest {
Thread t2 = new Thread(() -> { Thread t2 = new Thread(() -> {
try { try {
for (int i = 5; i < 10; i++) { for (int i = 5; i < 10; i++) {
monitor.getDb().upsertLot(new Lot( monitor.getDb().upsertLot(Lot.basic(
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat", 20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
200.0, "EUR", "https://example.com/" + i, null, false 200.0, "EUR", "https://example.com/" + i, null, false
)); ));
@@ -354,7 +354,7 @@ class TroostwijkMonitorTest {
monitor.getDb().upsertAuction(auction); monitor.getDb().upsertAuction(auction);
// Insert related lot // Insert related lot
var lot = new Lot( var lot = Lot.basic(
40000, 50000, 40000, 50000,
"Test Lot", "Test Lot",
"Description", "Description",

304
wiki/VALUATION.md Normal file
View File

@@ -0,0 +1,304 @@
# Auction Valuation Mathematics - Technical Reference
## 1. Fair Market Value (FMV) - Core Valuation Formula
The baseline valuation is calculated using a **weighted comparable sales approach**:
$$
FMV = \frac{\sum_{i=1}^{n} \left( P_i \cdot \omega_c \cdot \omega_t \cdot \omega_p \cdot \omega_h \right)}{\sum_{i=1}^{n} \left( \omega_c \cdot \omega_t \cdot \omega_p \cdot \omega_h \right)}
$$
**Variables:**
- $P_i$ = Final hammer price of comparable lot *i* (€)
- $\omega_c$ = **Condition weight**: $\exp(-\lambda_c \cdot |C_{target} - C_i|)$
- $\omega_t$ = **Time weight**: $\exp(-\lambda_t \cdot |T_{target} - T_i|)$
- $\omega_p$ = **Provenance weight**: $1 + \delta_p \cdot (P_{target} - P_i)$
- $\omega_h$ = **Historical weight**: $\left( \frac{1}{1 + e^{-kh \cdot (D_i - D_{median})}} \right)$
**Parameter Definitions:**
- $C \in [0, 10]$ = Condition score (10 = perfect)
- $T$ = Manufacturing year
- $P \in \{0,1\}$ = Provenance flag (1 = documented history)
- $D_i$ = Days since comparable sale
- $\lambda_c = 0.693$ = Condition decay constant (50% weight at 1-point difference)
- $\lambda_t = 0.048$ = Time decay constant (50% weight at 15-year difference)
- $\delta_p = 0.15$ = Provenance premium coefficient
- $kh = 0.01$ = Historical relevance coefficient
---
## 2. Condition Adjustment Multiplier
Normalizes prices across condition states:
$$
M_{cond} = \exp\left( \alpha_c \cdot \sqrt{C_{target}} - \beta_c \right)
$$
**Variables:**
- $\alpha_c = 0.15$ = Condition sensitivity parameter
- $\beta_c = 0.40$ = Baseline condition offset
- $C_{target}$ = Target lot condition score
**Interpretation:**
- $C = 10$ (mint): $M_{cond} = 1.48$ (48% premium over poor condition)
- $C = 5$ (average): $M_{cond} = 0.91$
---
## 3. Time-Based Depreciation Model
For equipment/machinery with measurable lifespan:
$$
V_{age} = V_{new} \cdot \left( 1 - \gamma \cdot \ln\left( 1 + \frac{Y_{current} - Y_{manu}}{Y_{expected}} \right) \right)
$$
**Variables:**
- $V_{new}$ = Original market value (€)
- $\gamma = 0.25$ = Depreciation aggressivity factor
- $Y_{current}$ = Current year
- $Y_{manu}$ = Manufacturing year
- $Y_{expected}$ = Expected useful life span (years)
**Example:** 10-year-old machinery with 25-year expected life retains 85% of value.
---
## 4. Provenance Premium Calculation
$$
\Delta_{prov} = V_{base} \cdot \left( \eta_0 + \eta_1 \cdot \ln(1 + N_{docs}) \right)
$$
**Variables:**
- $V_{base}$ = Base valuation without provenance (€)
- $N_{docs}$ = Number of verifiable provenance documents
- $\eta_0 = 0.08$ = Base provenance premium (8%)
- $\eta_1 = 0.035$ = Marginal document premium coefficient
---
## 5. Undervaluation Detection Score
Critical for identifying mispriced opportunities:
$$
U_{score} = \frac{FMV - P_{current}}{FMV} \cdot \sigma_{market} \cdot \left( 1 + \frac{B_{velocity}}{B_{threshold}} \right) \cdot \ln\left( 1 + \frac{W_{watch}}{W_{bid}} \right)
$$
**Variables:**
- $P_{current}$ = Current bid price (€)
- $\sigma_{market} \in [0,1]$ = Market volatility factor (from indices)
- $B_{velocity}$ = Bids per hour (bph)
- $B_{threshold} = 10$ bph = High-velocity threshold
- $W_{watch}$ = Watch count
- $W_{bid}$ = Bid count
**Trigger condition:** $U_{score} &gt; 0.25$ (25% undervaluation) with confidence &gt; 0.70
---
## 6. Bid Velocity Indicator (Competition Heat)
Measures real-time competitive intensity:
$$
\Lambda_b(t) = \frac{dB}{dt} \cdot \exp\left( -\lambda_{cool} \cdot (t - t_{last}) \right)
$$
**Variables:**
- $\frac{dB}{dt}$ = Bid frequency derivative (bids/minute)
- $\lambda_{cool} = 0.1$ = Cool-down decay constant
- $t_{last}$ = Timestamp of last bid (minutes)
**Interpretation:**
- $\Lambda_b &gt; 5$ = **Hot lot** (bidding war likely)
- $\Lambda_b &lt; 0.5$ = **Cold lot** (potential sleeper)
---
## 7. Final Price Prediction Model
Composite machine learning-style formula:
$$
\hat{P}_{final} = FMV \cdot \left( 1 + \epsilon_{bid} + \epsilon_{time} + \epsilon_{comp} \right)
$$
**Error Components:**
- **Bid momentum error**:
$$\epsilon_{bid} = \tanh\left( \phi_1 \cdot \Lambda_b - \phi_2 \cdot \frac{P_{current}}{FMV} \right)$$
- **Time-to-close error**:
$$\epsilon_{time} = \psi \cdot \exp\left( -\frac{t_{close}}{30} \right)$$
- **Competition error**:
$$\epsilon_{comp} = \rho \cdot \ln\left( 1 + \frac{W_{watch}}{50} \right)$$
**Parameters:**
- $\phi_1 = 0.15$, $\phi_2 = 0.10$ = Bid momentum coefficients
- $\psi = 0.20$ = Time pressure coefficient
- $\rho = 0.08$ = Competition coefficient
- $t_{close}$ = Minutes until close
**Confidence interval**:
$$
CI_{95\%} = \hat{P}_{final} \pm 1.96 \cdot \sigma_{residual}
$$
---
## 8. Bidding Strategy Recommendation Engine
Optimal max bid and timing:
$$
S_{max} =
\begin{cases}
FMV \cdot (1 - \theta_{agg}) & \text{if } U_{score} &gt; 0.20 \\
FMV \cdot (1 + \theta_{cons}) & \text{if } \Lambda_b &gt; 3 \\
\hat{P}_{final} - \delta_{margin} & \text{otherwise}
\end{cases}
$$
**Variables:**
- $\theta_{agg} = 0.10$ = Aggressive buyer discount target (10% below FMV)
- $\theta_{cons} = 0.05$ = Conservative buyer overbid tolerance
- $\delta_{margin} = €50$ = Minimum margin below predicted final
**Timing function**:
$$
t_{optimal} = t_{close} - \begin{cases}
5 \text{ min} & \text{if } \Lambda_b &lt; 1 \\
30 \text{ sec} & \text{if } \Lambda_b &gt; 5 \\
10 \text{ min} & \text{otherwise}
\end{cases}
$$
---
## Variable Reference Table
| Symbol | Variable | Unit | Data Source |
|--------|----------|------|-------------|
| $P_i$ | Comparable sale price | € | `bid_history.final` |
| $C$ | Condition score | [0,10] | Image analysis + text parsing |
| $T$ | Manufacturing year | Year | Lot description extraction |
| $W_{watch}$ | Number of watchers | Count | Page metadata |
| $\Lambda_b$ | Bid velocity | bids/min | `bid_history.timestamp` diff |
| $t_{close}$ | Time until close | Minutes | `lots.closing_time` - NOW() |
| $\sigma_{market}$ | Market volatility | [0,1] | `market_indices.price_change_30d` |
| $N_{docs}$ | Provenance documents | Count | PDF link analysis |
| $B_{velocity}$ | Bid acceleration | bph² | Second derivative of $\Lambda_b$ |
---
## Backend Implementation (Quarkus Pseudo-Code)
```java
@Inject
MLModelService mlModel;
public Valuation calculateFairMarketValue(Lot lot) {
List&lt;Comparable&gt; comparables = db.findComparables(lot, minSimilarity=0.75, limit=20);
double weightedSum = 0.0;
double weightSum = 0.0;
for (Comparable comp : comparables) {
double wc = Math.exp(-0.693 * Math.abs(lot.getConditionScore() - comp.getConditionScore()));
double wt = Math.exp(-0.048 * Math.abs(lot.getYear() - comp.getYear()));
double wp = 1 + 0.15 * (lot.hasProvenance() ? 1 : 0 - comp.hasProvenance() ? 1 : 0);
double weight = wc * wt * wp;
weightedSum += comp.getFinalPrice() * weight;
weightSum += weight;
}
double fm v = weightSum &gt; 0 ? weightedSum / weightSum : lot.getEstimatedMin();
// Apply condition multiplier
fm v *= Math.exp(0.15 * Math.sqrt(lot.getConditionScore()) - 0.40);
return new Valuation(fm v, calculateConfidence(comparables.size()));
}
public BiddingStrategy getBiddingStrategy(String lotId) {
var lot = db.getLot(lotId);
var bidHistory = db.getBidHistory(lotId);
var watchers = lot.getWatchCount();
// Analyze patterns
boolean isSnipeTarget = watchers &gt; 50 && bidHistory.size() &lt; 5;
boolean hasReserve = lot.getReservePrice() &gt; 0;
double bidVelocity = calculateBidVelocity(bidHistory);
// Strategy recommendation
String strategy = isSnipeTarget ? "SNIPING_DETECTED" :
(hasReserve && lot.getCurrentBid() &lt; lot.getReservePrice() * 0.9) ? "RESERVE_AVOID" :
bidVelocity &gt; 5.0 ? "AGGRESSIVE_COMPETITION" : "STANDARD";
return new BiddingStrategy(
strategy,
calculateRecommendedMax(lot),
isSnipeTarget ? "FINAL_30_SECONDS" : "FINAL_10_MINUTES",
getCompetitionLevel(watchers, bidHistory.size())
);
}
```
```sqlite
-- Core bidding intelligence
ALTER TABLE lots ADD COLUMN starting_bid DECIMAL(12,2);
ALTER TABLE lots ADD COLUMN estimated_min DECIMAL(12,2);
ALTER TABLE lots ADD COLUMN estimated_max DECIMAL(12,2);
ALTER TABLE lots ADD COLUMN reserve_price DECIMAL(12,2);
ALTER TABLE lots ADD COLUMN watch_count INTEGER DEFAULT 0;
ALTER TABLE lots ADD COLUMN first_bid_time TEXT;
ALTER TABLE lots ADD COLUMN last_bid_time TEXT;
ALTER TABLE lots ADD COLUMN bid_velocity DECIMAL(5,2);
-- Bid history (critical)
CREATE TABLE bid_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT REFERENCES lots(lot_id),
bid_amount DECIMAL(12,2) NOT NULL,
bid_time TEXT NOT NULL,
is_winning BOOLEAN DEFAULT FALSE,
is_autobid BOOLEAN DEFAULT FALSE,
bidder_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Valuation support
ALTER TABLE lots ADD COLUMN condition_score DECIMAL(3,2);
ALTER TABLE lots ADD COLUMN year_manufactured INTEGER;
ALTER TABLE lots ADD COLUMN provenance TEXT;
CREATE TABLE comparable_sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT REFERENCES lots(lot_id),
comparable_lot_id TEXT,
similarity_score DECIMAL(3,2),
price_difference_percent DECIMAL(5,2)
);
CREATE TABLE market_indices (
category TEXT NOT NULL,
manufacturer TEXT,
avg_price DECIMAL(12,2),
price_change_30d DECIMAL(5,2),
PRIMARY KEY (category, manufacturer)
);
-- Alert system
CREATE TABLE price_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT REFERENCES lots(lot_id),
alert_type TEXT CHECK(alert_type IN ('UNDervalued', 'ACCELERATING', 'RESERVE_IN_SIGHT')),
trigger_price DECIMAL(12,2),
is_triggered BOOLEAN DEFAULT FALSE
);
```