Fix mock tests

This commit is contained in:
Tour
2025-12-07 11:08:59 +01:00
parent 11a76e0292
commit 43b5fc03fd
8 changed files with 322 additions and 94 deletions

View File

@@ -20,7 +20,7 @@ set -e # Exit on error
# Configuration
REMOTE_HOST="tour@athena.lan"
REMOTE_VOLUME="shared-auction-data"
LOCAL_DB_PATH="c:/mnt/okcomputer/cache.db"
LOCAL_DB_PATH="c:/mnt/okcomputer/output/cache.db"
LOCAL_IMAGES_PATH="c:/mnt/okcomputer/images"
REMOTE_TMP="/tmp"

View File

@@ -5,6 +5,7 @@ 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.LocalDateTime;
import java.util.ArrayList;
@@ -121,6 +122,49 @@ public class AuctionMonitorResource {
}
}
/**
* GET /api/monitor/closing-soon
* Returns lots closing within the next specified hours (default: 24 hours)
*/
@GET
@Path("/closing-soon")
public Response getClosingSoon(@QueryParam("hours") @DefaultValue("24") int hours) {
try {
var lots = db.getAllLots();
var closingSoon = lots.stream()
.filter(lot -> lot.closingTime() != null)
.filter(lot -> lot.minutesUntilClose() > 0 && lot.minutesUntilClose() <= hours * 60)
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
.limit(100)
.toList();
return Response.ok(closingSoon).build();
} catch (Exception e) {
LOG.error("Failed to get closing soon lots", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* GET /api/monitor/lots/{lotId}/bid-history
* Returns bid history for a specific lot
*/
@GET
@Path("/lots/{lotId}/bid-history")
public Response getBidHistory(@PathParam("lotId") String lotId) {
try {
var history = db.getBidHistory(lotId);
return Response.ok(history).build();
} catch (Exception e) {
LOG.error("Failed to get bid history for lot {}", lotId, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/**
* POST /api/monitor/trigger/scraper-import
* Manually trigger scraper import workflow

View File

@@ -544,42 +544,19 @@ public class DatabaseService {
*/
synchronized List<Lot> getActiveLots() throws SQLException {
List<Lot> list = new ArrayList<>();
var sql = "SELECT lot_id, sale_id, title, description, manufacturer, type, year, category, " +
var sql = "SELECT lot_id, sale_id as auction_id, title, description, manufacturer, type, year, category, " +
"current_bid, currency, url, closing_time, closing_notified FROM lots";
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
while (rs.next()) {
var closingStr = rs.getString("closing_time");
LocalDateTime closing = null;
if (closingStr != null && !closingStr.isBlank()) {
try {
closing = LocalDateTime.parse(closingStr);
} catch (Exception e) {
log.debug("Invalid closing_time format for lot {}: {}", rs.getLong("lot_id"), closingStr);
}
try {
// Use ScraperDataAdapter to handle TEXT parsing from legacy database
var lot = ScraperDataAdapter.fromScraperLot(rs);
list.add(lot);
} catch (Exception e) {
log.warn("Failed to parse lot {}: {}", rs.getString("lot_id"), e.getMessage());
}
list.add(new Lot(
rs.getLong("sale_id"),
rs.getLong("lot_id"),
rs.getString("title"),
rs.getString("description"),
rs.getString("manufacturer"),
rs.getString("type"),
rs.getInt("year"),
rs.getString("category"),
rs.getDouble("current_bid"),
rs.getString("currency"),
rs.getString("url"),
closing,
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
));
}
}
return list;
@@ -629,6 +606,44 @@ public class DatabaseService {
ps.executeUpdate();
}
}
/**
* Retrieves bid history for a specific lot
*/
synchronized List<BidHistory> getBidHistory(String lotId) throws SQLException {
List<BidHistory> history = new ArrayList<>();
var sql = "SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number " +
"FROM bid_history WHERE lot_id = ? ORDER BY bid_time DESC LIMIT 100";
try (var conn = DriverManager.getConnection(url);
var ps = conn.prepareStatement(sql)) {
ps.setString(1, lotId);
var rs = ps.executeQuery();
while (rs.next()) {
LocalDateTime bidTime = null;
var bidTimeStr = rs.getString("bid_time");
if (bidTimeStr != null && !bidTimeStr.isBlank()) {
try {
bidTime = LocalDateTime.parse(bidTimeStr);
} catch (Exception e) {
log.debug("Invalid bid_time format: {}", bidTimeStr);
}
}
history.add(new BidHistory(
rs.getInt("id"),
rs.getString("lot_id"),
rs.getDouble("bid_amount"),
bidTime,
rs.getInt("is_autobid") != 0,
rs.getString("bidder_id"),
rs.getInt("bidder_number")
));
}
}
return history;
}
/**
* Imports auctions from scraper's schema format.

View File

@@ -4,6 +4,19 @@ import lombok.With;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* Represents a bid in the bid history
*/
record BidHistory(
int id,
String lotId,
double bidAmount,
LocalDateTime bidTime,
boolean isAutobid,
String bidderId,
Integer bidderNumber
) {}
/// 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.

View File

@@ -37,6 +37,8 @@ public class ObjectDetectionService {
private final Net net;
private final List<String> classNames;
private final boolean enabled;
private int warnCount = 0;
private static final int MAX_WARNINGS = 5;
ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
// Check if model files exist
@@ -101,23 +103,44 @@ public class ObjectDetectionService {
// Postprocess: for each detection compute score and choose class
var confThreshold = 0.5f;
for (var out : outs) {
for (var i = 0; i < out.rows(); i++) {
var data = out.get(i, 0);
if (data == null) continue;
// YOLO output shape: [num_detections, 85] where 85 = 4 (bbox) + 1 (objectness) + 80 (classes)
int numDetections = out.rows();
int numElements = out.cols();
int expectedLength = 5 + classNames.size();
// The first 5 numbers are bounding box, then class scores
// Check if data has enough elements before copying
int expectedLength = 5 + classNames.size();
if (data.length < expectedLength) {
log.warn("Detection data too short: expected {} elements, got {}. Skipping detection.",
expectedLength, data.length);
continue;
if (numElements < expectedLength) {
// Rate-limit warnings to prevent thread blocking from excessive logging
if (warnCount < MAX_WARNINGS) {
log.warn("Output matrix has wrong dimensions: expected {} columns, got {}. Output shape: [{}, {}]",
expectedLength, numElements, numDetections, numElements);
warnCount++;
if (warnCount == MAX_WARNINGS) {
log.warn("Suppressing further dimension warnings (reached {} warnings)", MAX_WARNINGS);
}
}
continue;
}
for (var i = 0; i < numDetections; i++) {
// Get entire row (all 85 elements)
var data = new double[numElements];
for (int j = 0; j < numElements; j++) {
data[j] = out.get(i, j)[0];
}
// Extract objectness score (index 4) and class scores (index 5+)
double objectness = data[4];
if (objectness < confThreshold) {
continue; // Skip low-confidence detections
}
// Extract class scores
var scores = new double[classNames.size()];
System.arraycopy(data, 5, scores, 0, scores.length);
System.arraycopy(data, 5, scores, 0, Math.min(scores.length, data.length - 5));
var classId = argMax(scores);
var confidence = scores[classId];
var confidence = scores[classId] * objectness; // Combine objectness with class confidence
if (confidence > confThreshold) {
var label = classNames.get(classId);
if (!labels.contains(label)) {

View File

@@ -26,6 +26,20 @@ public class ValuationAnalyticsResource {
@Inject
DatabaseService db;
/**
* GET /api/analytics/valuation/health
* Health check endpoint to verify API availability
*/
@GET
@Path("/valuation/health")
public Response healthCheck() {
return Response.ok(Map.of(
"status", "healthy",
"service", "valuation-analytics",
"timestamp", java.time.LocalDateTime.now().toString()
)).build();
}
/**
* POST /api/analytics/valuation
* Main valuation endpoint that calculates FMV, undervaluation score,

View File

@@ -6,7 +6,6 @@
<title>Auctiora - Intelligence Dashboard</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/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
:root {
@@ -757,6 +756,12 @@ function showToast(message, type = 'info', duration = 4000) {
// Initialize dashboard
document.addEventListener('DOMContentLoaded', function() {
// Check for missing CDN dependencies
if (typeof Plotly === 'undefined') {
console.warn('Plotly.js not loaded - charts will be disabled');
addLog('Running in offline mode - charts disabled', 'warning');
}
addLog('Dashboard v2.1 initialized with predictive analytics');
startUptimeCounter();
fetchAllData();
@@ -1029,7 +1034,13 @@ function updateCountryChart(data) {
font: { size: 12 }
};
Plotly.newPlot('country-chart', [chartData], layout, {responsive: true});
// Check if Plotly is available before using it
if (typeof Plotly !== 'undefined') {
Plotly.newPlot('country-chart', [chartData], layout, {responsive: true});
} else {
console.warn('Plotly not loaded - chart rendering skipped');
document.getElementById('country-chart').innerHTML = '<div class="text-gray-500 text-sm p-4">Chart library not available (offline mode)</div>';
}
// Update summary stats
document.getElementById('top-country').textContent = countries[0] || '--';
@@ -1061,7 +1072,13 @@ function updateCategoryChart(data) {
font: { size: 12 }
};
Plotly.newPlot('category-chart', [chartData], layout, {responsive: true});
// Check if Plotly is available before using it
if (typeof Plotly !== 'undefined') {
Plotly.newPlot('category-chart', [chartData], layout, {responsive: true});
} else {
console.warn('Plotly not loaded - chart rendering skipped');
document.getElementById('category-chart').innerHTML = '<div class="text-gray-500 text-sm p-4">Chart library not available (offline mode)</div>';
}
// Update summary stats
document.getElementById('top-category').textContent = categories[0] || '--';
@@ -1105,7 +1122,13 @@ function updateTrendChart(data) {
font: { size: 12 }
};
Plotly.newPlot('trend-chart', [trace1, trace2], layout, {responsive: true});
// Check if Plotly is available before using it
if (typeof Plotly !== 'undefined') {
Plotly.newPlot('trend-chart', [trace1, trace2], layout, {responsive: true});
} else {
console.warn('Plotly not loaded - chart rendering skipped');
document.getElementById('trend-chart').innerHTML = '<div class="text-gray-500 text-sm p-4">Chart library not available (offline mode)</div>';
}
}
// Update insights

View File

@@ -10,7 +10,6 @@
<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 {
@@ -639,6 +638,93 @@ async function recalculate() {
showLoadingState(false);
}
// Helper function: Calculate undervaluation score
// Formula: U_score = (FMV - P_current)/FMV · σ_market · (1 + B_velocity/10) · ln(1 + W_watch/W_bid)
function calculateUndervaluationScore(params, fmv) {
if (fmv <= 0) return 0.0;
const priceGap = (fmv - params.currentBid) / fmv;
const velocityFactor = 1 + params.bidVelocity / 10.0;
const watchRatio = Math.log(1 + params.watchCount / Math.max(params.bidCount, 1));
const uScore = priceGap * params.marketVolatility * velocityFactor * watchRatio;
return Math.max(0.0, Math.round(uScore * 1000) / 1000);
}
// Helper function: Predict final price
// Formula: P̂_final = FMV · (1 + ε_bid + ε_time + ε_comp)
function calculateFinalPrice(params, fmv) {
// Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/FMV)
const epsilonBid = Math.tanh(0.15 * params.bidVelocity - 0.10 * (params.currentBid / fmv));
// Time pressure error: ε_time = ψ · exp(-t_close/30)
const epsilonTime = 0.20 * Math.exp(-params.minutesUntilClose / 30.0);
// Competition error: ε_comp = ρ · ln(1 + W_watch/50)
const epsilonComp = 0.08 * Math.log(1 + params.watchCount / 50.0);
const predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
// 95% confidence interval: ± 1.96 · σ_residual
const residualStdDev = fmv * 0.08;
const ciLower = predictedPrice - 1.96 * residualStdDev;
const ciUpper = predictedPrice + 1.96 * residualStdDev;
return {
predictedPrice: Math.round(predictedPrice * 100) / 100,
confidenceIntervalLower: Math.round(ciLower * 100) / 100,
confidenceIntervalUpper: Math.round(ciUpper * 100) / 100,
components: {
bidMomentum: Math.round(epsilonBid * 1000) / 1000,
timePressure: Math.round(epsilonTime * 1000) / 1000,
competition: Math.round(epsilonComp * 1000) / 1000
}
};
}
// Helper function: Generate bidding strategy
function generateBiddingStrategy(params, fmv, prediction) {
const strategy = {
competitionLevel: 'MEDIUM',
recommendedTiming: 'FINAL_10_MINUTES',
recommendedTimingText: 'FINAL 10 MINUTES',
maxBid: 0,
analysis: '',
riskFactors: []
};
// Determine competition level
if (params.bidVelocity > 5.0) {
strategy.competitionLevel = 'HIGH';
strategy.recommendedTiming = 'FINAL_30_SECONDS';
strategy.recommendedTimingText = 'FINAL 30 SECONDS';
strategy.maxBid = prediction.predictedPrice + 50;
strategy.riskFactors = ['Bidding war likely', 'Sniping detected'];
} else if (params.minutesUntilClose < 10) {
strategy.competitionLevel = 'EXTREME';
strategy.recommendedTiming = 'FINAL_10_SECONDS';
strategy.recommendedTimingText = 'FINAL 10 SECONDS';
strategy.maxBid = prediction.predictedPrice * 1.02;
strategy.riskFactors = ['Last-minute sniping', 'Price volatility'];
} else {
const undervaluationScore = calculateUndervaluationScore(params, fmv.value);
if (undervaluationScore > 0.25) {
strategy.maxBid = fmv.value * 1.05;
strategy.analysis = 'Significant undervaluation detected. Consider aggressive bidding.';
} else {
strategy.maxBid = fmv.value * 1.03;
}
strategy.riskFactors = ['Standard competition level'];
}
// Generate analysis
strategy.analysis = `Bid velocity is ${params.bidVelocity.toFixed(1)} bids/min with ${params.watchCount} watchers. ${strategy.competitionLevel} competition detected. Predicted final: €${prediction.predictedPrice.toFixed(2)}.`;
strategy.maxBid = Math.round(strategy.maxBid * 100) / 100;
return strategy;
}
// Fallback mock calculation (original logic)
async function calculateMockFallback(params) {
addLog('Using fallback calculation...', 'info');
@@ -654,9 +740,9 @@ async function calculateMockFallback(params) {
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 wc = Math.exp(-CONSTANTS.lambda_c * Math.abs(params.conditionScore - comp.condition));
const wt = Math.exp(-CONSTANTS.lambda_t * Math.abs(params.manufacturingYear - comp.year));
const wp = 1 + CONSTANTS.delta_p * (params.provenanceDocs > 0 ? 1 : 0 - comp.provenance);
const wh = 1 / (1 + Math.exp(-CONSTANTS.kh * (comp.days_ago - 45)));
const weight = wc * wt * wp * wh;
@@ -664,12 +750,12 @@ async function calculateMockFallback(params) {
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);
let fmv = weightSum > 0 ? weightedSum / weightSum : (params.estimatedMin + params.estimatedMax) / 2;
const mCond = Math.exp(CONSTANTS.alpha_c * Math.sqrt(params.conditionScore) - CONSTANTS.beta_c);
fmv *= mCond;
if (params.n_docs > 0) {
const provPremium = CONSTANTS.delta_p + 0.05 * Math.log(1 + params.n_docs);
if (params.provenanceDocs > 0) {
const provPremium = CONSTANTS.delta_p + 0.05 * Math.log(1 + params.provenanceDocs);
fmv *= (1 + provPremium);
}
@@ -678,7 +764,7 @@ async function calculateMockFallback(params) {
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,
provenancePremium: params.provenanceDocs > 0 ? CONSTANTS.delta_p + 0.05 * Math.log(1 + params.provenanceDocs) : 0,
comparablesUsed: comparables.length,
confidence: 0.85,
weightedComparables: comparables.map(comp => ({
@@ -686,9 +772,9 @@ async function calculateMockFallback(params) {
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,
conditionWeight: Math.round(Math.exp(-CONSTANTS.lambda_c * Math.abs(params.conditionScore - comp.condition)) * 1000) / 1000,
timeWeight: Math.round(Math.exp(-CONSTANTS.lambda_t * Math.abs(params.manufacturingYear - comp.year)) * 1000) / 1000,
provenanceWeight: Math.round((1 + CONSTANTS.delta_p * (params.provenanceDocs > 0 ? 1 : 0 - comp.provenance)) * 1000) / 1000,
historicalWeight: Math.round((1 / (1 + Math.exp(-CONSTANTS.kh * (comp.days_ago - 45)))) * 1000) / 1000
}
}))
@@ -803,47 +889,57 @@ function generateSensitivityCharts() {
const conditionRange = Array.from({length: 21}, (_, i) => i * 0.5);
const conditionValues = conditionRange.map(c => {
const params = getInputParams();
params.c_target = c;
params.conditionScore = 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});
// Check if Plotly is available before using it
if (typeof Plotly !== 'undefined') {
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});
} else {
document.getElementById('condition-chart').innerHTML = '<div class="text-gray-500 text-sm p-4">Chart library not available (offline mode)</div>';
}
// Time sensitivity chart
const timeRange = Array.from({length: 20}, (_, i) => i * 5);
const timeValues = timeRange.map(t => {
const params = getInputParams();
params.t_close = t;
params.minutesUntilClose = 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});
// Check if Plotly is available before using it
if (typeof Plotly !== 'undefined') {
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});
} else {
document.getElementById('time-chart').innerHTML = '<div class="text-gray-500 text-sm p-4">Chart library not available (offline mode)</div>';
}
}
// Simplified mock FMV for chart generation
@@ -857,22 +953,22 @@ function calculateMockFMV(params) {
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 wc = Math.exp(-CONSTANTS.lambda_c * Math.abs(params.conditionScore - comp.condition));
const wt = Math.exp(-CONSTANTS.lambda_t * Math.abs(params.manufacturingYear - 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);
const mCond = Math.exp(CONSTANTS.alpha_c * Math.sqrt(params.conditionScore) - 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);
const epsilon_bid = Math.tanh(CONSTANTS.phi_1 * params.bidVelocity);
const epsilon_time = CONSTANTS.psi * Math.exp(-params.minutesUntilClose / 30);
return fmv * (1 + epsilon_bid + epsilon_time);
}