← 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,
  };
}