Fix mock tests
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
364
src/main/java/auctiora/ValuationAnalyticsResource.java
Normal file
364
src/main/java/auctiora/ValuationAnalyticsResource.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
0
src/main/resources/META-INF/resources/script.js
Normal file
0
src/main/resources/META-INF/resources/script.js
Normal file
0
src/main/resources/META-INF/resources/style.css
Normal file
0
src/main/resources/META-INF/resources/style.css
Normal file
990
src/main/resources/META-INF/resources/valuation-analytics.html
Normal file
990
src/main/resources/META-INF/resources/valuation-analytics.html
Normal 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">
|
||||
P̂<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>
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user