← back to Govarbitrage
src/engines/valuation.ts
85 lines
import { clamp, round2 } from "@/lib/utils";
import { CONDITION_RETAIL_FACTOR } from "./constants";
import type { Valuation, ValuationInput } from "./types";
/**
* Derive the full valuation ladder from a single retail anchor plus item facts.
*
* The AI/research layer supplies the hard-to-know inputs (a `newRetail` anchor,
* a `demandScore`, identification confidence, comparable count); everything
* else is derived deterministically here so the numbers are reproducible and
* explainable rather than a black box.
*/
export function computeValuation(input: ValuationInput): Valuation {
const qty = Math.max(1, input.quantity || 1);
const perUnitRetail = Math.max(0, input.newRetail);
const condFactor = CONDITION_RETAIL_FACTOR[input.condition] ?? 0.35;
const demand = clamp(input.demandScore ?? 50, 0, 100);
// Per-unit retail figures.
const newRetail = perUnitRetail;
const newReplacement = round2(perUnitRetail * 1.05); // like-for-like buy-new cost
const avgRetail = round2(perUnitRetail * 0.92); // street average vs MSRP
// Used market, anchored off condition.
const usedSoldPrice = round2(perUnitRetail * condFactor);
const usedAskingPrice = round2(usedSoldPrice * 1.25);
const usedLow = round2(usedSoldPrice * 0.8);
const usedHigh = round2(usedSoldPrice * 1.3);
// Channel values (per unit).
const wholesaleValue = round2(usedSoldPrice * 0.6);
const liquidationValue = round2(usedSoldPrice * 0.42);
const sellTodayValue = round2(liquidationValue * 1.1); // fire-sale, today
// Time-horizon values: the longer you wait, the closer to asking you get,
// scaled by how much demand there is.
const patience = 0.5 + demand / 200; // 0.5..1.0
const value7Day = round2(usedSoldPrice * (0.8 + 0.1 * patience));
const value30Day = round2(usedSoldPrice * (0.95 + 0.1 * patience));
const value90Day = round2(usedAskingPrice * (0.85 + 0.1 * patience));
// Expected sale price: demand-weighted blend across horizons.
const expectedSalePrice = round2(
value7Day * 0.2 + value30Day * 0.5 + value90Day * 0.3,
);
// Probability of sale within 90 days, driven by demand + condition quality.
const probabilityOfSale = round2(
clamp(0.35 + demand / 200 + (condFactor - 0.35) * 0.4, 0.1, 0.98),
);
// Days until sold: high demand sells fast; low demand lingers.
const daysUntilSold = Math.round(clamp(90 - demand * 0.75, 5, 120));
// Confidence: how much do we trust these numbers? Built from identification
// confidence and the number of real comparables backing the estimate.
const idConf = clamp(input.identificationConfidence ?? 0.5, 0, 1);
const comps = input.comparableCount ?? 0;
const confidenceScore = round2(
clamp(idConf * 60 + Math.min(comps, 8) * 5, 0, 100),
);
// Return per-unit valuations but scale the aggregate resale-relevant figure
// (expectedSalePrice) by quantity, since a lot of N sells N units.
return {
newRetail,
newReplacement,
avgRetail,
usedSoldPrice,
usedAskingPrice,
usedLow,
usedHigh,
wholesaleValue,
liquidationValue,
sellTodayValue,
value7Day,
value30Day,
value90Day,
expectedSalePrice: round2(expectedSalePrice * qty),
probabilityOfSale,
daysUntilSold,
confidenceScore,
};
}