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

@@ -502,13 +502,45 @@ public class AuctionMonitorResource {
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("N/A");
insights.add(Map.of(
"icon", "fa-globe",
"title", topCountry + " leading",
"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();
} catch (Exception 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
public static class TrendHour {
public int hour;
public int lots;
public int bids;
public TrendHour(int hour, int lots, int bids) {
this.hour = hour;
this.lots = lots;

View File

@@ -573,7 +573,12 @@ public class DatabaseService {
rs.getString("currency"),
rs.getString("url"),
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.LocalDateTime;
/**
* Represents a lot (kavel) in an auction.
* Data typically populated by the external scraper process.
* This project enriches the data with image analysis and monitoring.
*/
/// Represents a lot (kavel) in an auction.
/// Data typically populated by the external scraper process.
/// This project enriches the data with image analysis and monitoring.
@With
record Lot(
long saleId,
@@ -23,23 +21,132 @@ record Lot(
String currency,
String url,
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() {
if (closingTime == null) return Long.MAX_VALUE;
return Duration.between(LocalDateTime.now(), closingTime).toMinutes();
}
public Lot withCurrentBid(double newBid) {
return new Lot(saleId, lotId, title, description,
manufacturer, type, year, category,
newBid, currency, url, closingTime, closingNotified);
// Intelligence Methods
/// Calculate total cost including VAT and buyer premium
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;
}
public Lot withClosingNotified(boolean flag) {
return new Lot(saleId, lotId, title, description,
manufacturer, type, year, category,
currentBid, currency, url, closingTime, flag);
/// Calculate next bid amount using API-provided increment
public double calculateNextBid() {
if (nextBidStepInCents != null && nextBidStepInCents > 0) {
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);
// Mark as notified
var updated = new Lot(
var updated = Lot.basic(
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
lot.currentBid(), lot.currency(), lot.url(),

View File

@@ -67,7 +67,12 @@ public class ScraperDataAdapter {
currency,
rs.getString("url"),
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);
// Mark as notified
var updated = new Lot(
var updated = Lot.basic(
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
lot.currentBid(), lot.currency(), lot.url(),

View File

@@ -267,6 +267,63 @@
</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 -->
<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')">
Title <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('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')">
Current Bid <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">
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 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>
@@ -641,7 +704,10 @@ let dashboardState = {
closingSoon: [],
countryDistribution: {},
categoryDistribution: {},
trendData: {}
trendData: {},
sleepers: [],
bargains: [],
popular: []
},
filters: {
auction: '',
@@ -748,7 +814,8 @@ async function fetchAllData() {
fetchStatistics(),
fetchRateLimitStats(),
fetchClosingSoon(),
fetchChartData()
fetchChartData(),
fetchIntelligenceData()
]);
updateLastUpdate();
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
async function fetchStatus() {
try {
@@ -1179,12 +1278,41 @@ function updateClosingSoonTable(data = null) {
const urgencyIcon = minutesLeft < 10 ? 'fa-exclamation-circle' :
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 `
<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 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 text-sm text-gray-500">${lot.closingTime ? new Date(lot.closingTime).toLocaleString() : 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap">
<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">
<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
@@ -1394,6 +1522,46 @@ window.addEventListener('offline', () => {
showToast('Connection lost - working offline', '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>
</body>
</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
@DisplayName("Should insert and retrieve lot")
void testUpsertAndGetLot() throws SQLException {
var lot = new Lot(
var lot = Lot.basic(
12345, // saleId
67890, // lotId
"Forklift",
@@ -180,7 +180,7 @@ class DatabaseServiceTest {
@Test
@DisplayName("Should update lot current bid")
void testUpdateLotCurrentBid() throws SQLException {
var lot = new Lot(
var lot = Lot.basic(
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/22222", null, false
);
@@ -188,7 +188,7 @@ class DatabaseServiceTest {
db.upsertLot(lot);
// Update bid
var updatedLot = new Lot(
var updatedLot = Lot.basic(
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
250.00, "EUR", "https://example.com/lot/22222", null, false
);
@@ -208,7 +208,7 @@ class DatabaseServiceTest {
@Test
@DisplayName("Should update lot notification flags")
void testUpdateLotNotificationFlags() throws SQLException {
var lot = new Lot(
var lot = Lot.basic(
33333, 44444, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/44444", null, false
);
@@ -216,7 +216,7 @@ class DatabaseServiceTest {
db.upsertLot(lot);
// Update notification flag
var updatedLot = new Lot(
var updatedLot = Lot.basic(
33333, 44444, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/44444", null, true
);
@@ -237,7 +237,7 @@ class DatabaseServiceTest {
@DisplayName("Should insert and retrieve image records")
void testInsertAndGetImages() throws SQLException {
// First create a lot
var lot = new Lot(
var lot = Lot.basic(
55555, 66666, "Test Lot", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/66666", null, false
);
@@ -268,7 +268,7 @@ class DatabaseServiceTest {
int initialCount = db.getImageCount();
// Add a lot and image
var lot = new Lot(
var lot = Lot.basic(
77777, 88888, "Test Lot", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/88888", null, false
);
@@ -304,7 +304,7 @@ class DatabaseServiceTest {
@Test
@DisplayName("Should handle lots with null closing time")
void testLotWithNullClosingTime() throws SQLException {
var lot = new Lot(
var lot = Lot.basic(
98765, 12340, "Test Item", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/12340", null, false
);
@@ -323,7 +323,7 @@ class DatabaseServiceTest {
@Test
@DisplayName("Should retrieve active lots only")
void testGetActiveLots() throws SQLException {
var activeLot = new Lot(
var activeLot = Lot.basic(
11111, 55551, "Active Lot", "Description", "", "", 0, "Category",
100.00, "EUR", "https://example.com/lot/55551",
LocalDateTime.now().plusDays(1), false
@@ -345,7 +345,7 @@ class DatabaseServiceTest {
Thread t1 = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
db.upsertLot(new Lot(
db.upsertLot(Lot.basic(
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
100.0, "EUR", "https://example.com/" + i, null, false
));
@@ -358,7 +358,7 @@ class DatabaseServiceTest {
Thread t2 = new Thread(() -> {
try {
for (int i = 10; i < 20; i++) {
db.upsertLot(new Lot(
db.upsertLot(Lot.basic(
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
200.0, "EUR", "https://example.com/" + i, null, false
));

View File

@@ -84,7 +84,7 @@ class IntegrationTest {
db.upsertAuction(auction);
// Step 2: Import lots for this auction
var lot1 = new Lot(
var lot1 = Lot.basic(
12345, 10001,
"Toyota Forklift 2.5T",
"Electric forklift in excellent condition",
@@ -99,7 +99,7 @@ class IntegrationTest {
false
);
var lot2 = new Lot(
var lot2 = Lot.basic(
12345, 10002,
"Office Furniture Set",
"Desks, chairs, and cabinets",
@@ -159,7 +159,7 @@ class IntegrationTest {
.orElseThrow();
// Update bid
var updatedLot = new Lot(
var updatedLot = Lot.basic(
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
2000.00, // Increased from 1500.00
@@ -190,7 +190,7 @@ class IntegrationTest {
@DisplayName("Integration: Closing alert workflow")
void testClosingAlertWorkflow() throws SQLException {
// Create lot closing soon
var closingSoon = new Lot(
var closingSoon = Lot.basic(
12345, 20001,
"Closing Soon Item",
"Description",
@@ -217,7 +217,7 @@ class IntegrationTest {
);
// Mark as notified
var notified = new Lot(
var notified = Lot.basic(
closingSoon.saleId(), closingSoon.lotId(), closingSoon.title(),
closingSoon.description(), closingSoon.manufacturer(), closingSoon.type(),
closingSoon.year(), closingSoon.category(), closingSoon.currentBid(),
@@ -310,7 +310,7 @@ class IntegrationTest {
@DisplayName("Integration: Object detection value estimation workflow")
void testValueEstimationWorkflow() throws SQLException {
// Create lot with detected objects
var lot = new Lot(
var lot = Lot.basic(
40000, 50000,
"Construction Equipment",
"Heavy machinery for construction",
@@ -378,7 +378,7 @@ class IntegrationTest {
var lotThread = new Thread(() -> {
try {
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",
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")
void testLotTracking() throws SQLException {
// Insert test lot
var lot = new Lot(
var lot = Lot.basic(
11111, 22222,
"Test Forklift",
"Electric forklift in good condition",
@@ -98,7 +98,7 @@ class TroostwijkMonitorTest {
@DisplayName("Should monitor lots closing soon")
void testClosingSoonMonitoring() throws SQLException {
// Insert lot closing in 4 minutes
var closingSoon = new Lot(
var closingSoon = Lot.basic(
33333, 44444,
"Closing Soon Item",
"Description",
@@ -128,7 +128,7 @@ class TroostwijkMonitorTest {
@Test
@DisplayName("Should identify lots with time remaining")
void testTimeRemainingCalculation() throws SQLException {
var futureLot = new Lot(
var futureLot = Lot.basic(
55555, 66666,
"Future Lot",
"Description",
@@ -158,7 +158,7 @@ class TroostwijkMonitorTest {
@Test
@DisplayName("Should handle lots without closing time")
void testLotsWithoutClosingTime() throws SQLException {
var noClosing = new Lot(
var noClosing = Lot.basic(
77777, 88888,
"No Closing Time",
"Description",
@@ -188,7 +188,7 @@ class TroostwijkMonitorTest {
@Test
@DisplayName("Should track notification status")
void testNotificationStatusTracking() throws SQLException {
var lot = new Lot(
var lot = Lot.basic(
99999, 11110,
"Test Notification",
"Description",
@@ -206,7 +206,7 @@ class TroostwijkMonitorTest {
monitor.getDb().upsertLot(lot);
// Update notification flag
var notified = new Lot(
var notified = Lot.basic(
99999, 11110,
"Test Notification",
"Description",
@@ -236,7 +236,7 @@ class TroostwijkMonitorTest {
@Test
@DisplayName("Should update bid amounts")
void testBidAmountUpdates() throws SQLException {
var lot = new Lot(
var lot = Lot.basic(
12121, 13131,
"Bid Update Test",
"Description",
@@ -254,7 +254,7 @@ class TroostwijkMonitorTest {
monitor.getDb().upsertLot(lot);
// Simulate bid increase
var higherBid = new Lot(
var higherBid = Lot.basic(
12121, 13131,
"Bid Update Test",
"Description",
@@ -287,7 +287,7 @@ class TroostwijkMonitorTest {
Thread t1 = new Thread(() -> {
try {
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",
100.0, "EUR", "https://example.com/" + i, null, false
));
@@ -300,7 +300,7 @@ class TroostwijkMonitorTest {
Thread t2 = new Thread(() -> {
try {
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",
200.0, "EUR", "https://example.com/" + i, null, false
));
@@ -354,7 +354,7 @@ class TroostwijkMonitorTest {
monitor.getDb().upsertAuction(auction);
// Insert related lot
var lot = new Lot(
var lot = Lot.basic(
40000, 50000,
"Test Lot",
"Description",