[object Object]

← back to Govarbitrage

Phase 2: deterministic valuation/cost/scoring engines + 16 passing Vitest tests

111d8f1d07d1579603019d38d9c6e16d68b42ac9 · 2026-07-09 22:27:46 -0700 · Steve Abrams

Files touched

Diff

commit 111d8f1d07d1579603019d38d9c6e16d68b42ac9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 9 22:27:46 2026 -0700

    Phase 2: deterministic valuation/cost/scoring engines + 16 passing Vitest tests
---
 src/engines/constants.ts      |  56 ++++++++++++
 src/engines/costs.test.ts     |  60 +++++++++++++
 src/engines/costs.ts          | 162 ++++++++++++++++++++++++++++++++++
 src/engines/freight.ts        |  47 ++++++++++
 src/engines/scoring.test.ts   |  72 ++++++++++++++++
 src/engines/scoring.ts        | 196 ++++++++++++++++++++++++++++++++++++++++++
 src/engines/types.ts          | 133 ++++++++++++++++++++++++++++
 src/engines/valuation.test.ts |  85 ++++++++++++++++++
 src/engines/valuation.ts      |  84 ++++++++++++++++++
 src/lib/db.ts                 |  13 +++
 src/lib/utils.ts              |  50 +++++++++++
 vitest.config.ts              |  15 ++++
 12 files changed, 973 insertions(+)

diff --git a/src/engines/constants.ts b/src/engines/constants.ts
new file mode 100644
index 0000000..73e70c6
--- /dev/null
+++ b/src/engines/constants.ts
@@ -0,0 +1,56 @@
+import type { ConditionKey, SourceKey } from "./types";
+
+// Fraction of *new retail* a used unit fetches, by condition. These are the
+// backbone of the valuation model; documented so estimates are auditable.
+export const CONDITION_RETAIL_FACTOR: Record<ConditionKey, number> = {
+  NEW: 0.78,
+  LIKE_NEW: 0.62,
+  USED_GOOD: 0.46,
+  USED_FAIR: 0.31,
+  FOR_PARTS: 0.12,
+  UNKNOWN: 0.35,
+};
+
+// Typical repair burden as a fraction of new retail, by condition.
+export const CONDITION_REPAIR_FACTOR: Record<ConditionKey, number> = {
+  NEW: 0,
+  LIKE_NEW: 0.01,
+  USED_GOOD: 0.03,
+  USED_FAIR: 0.07,
+  FOR_PARTS: 0.18,
+  UNKNOWN: 0.05,
+};
+
+// Buyer's premium charged by each source (fraction of hammer/winning bid).
+export const BUYER_PREMIUM_RATE: Record<SourceKey, number> = {
+  GOVDEALS: 0.1,
+  PUBLIC_SURPLUS: 0.1,
+  GSA_AUCTIONS: 0,
+  COUNTY: 0.1,
+  STATE_SURPLUS: 0.08,
+  UNIVERSITY_SURPLUS: 0.1,
+  MUNICIBID: 0.1,
+  BID4ASSETS: 0.1,
+  CSV: 0.1,
+  EXTENSION: 0.1,
+  OTHER: 0.1,
+};
+
+// Marketplace + payment fee rates (fraction of resale price).
+export const MARKETPLACE_FEE_RATE = 0.132; // eBay-typical final value fee
+export const PAYMENT_FEE_RATE = 0.03; // managed-payments processing
+
+// Prep/handling cost assumptions (US dollars).
+export const PACKING_BASE = 12;
+export const PACKING_PER_LB = 0.15;
+export const PHOTOGRAPHY_COST = 8;
+export const LISTING_LABOR_COST = 10;
+export const TESTING_COST_PER_UNIT = 15;
+export const PICKUP_LABOR_HOURLY = 35;
+export const STORAGE_PER_MONTH = 18;
+
+// Weight threshold (lbs) above which an item ships as LTL freight, not parcel.
+export const FREIGHT_THRESHOLD_LBS = 150;
+
+// Target ROI used to back-solve the recommended maximum bid.
+export const TARGET_ROI = 0.4;
diff --git a/src/engines/costs.test.ts b/src/engines/costs.test.ts
new file mode 100644
index 0000000..10c67e9
--- /dev/null
+++ b/src/engines/costs.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { computeCosts } from "./costs";
+import type { CostInput } from "./types";
+
+const base: CostInput = {
+  source: "GOVDEALS",
+  winningBid: 500,
+  quantity: 1,
+  weightLbs: 40,
+  condition: "USED_GOOD",
+  expectedSalePrice: 9000,
+  daysUntilSold: 30,
+};
+
+describe("computeCosts", () => {
+  it("applies the source buyer premium", () => {
+    const c = computeCosts(base);
+    // GovDeals premium is 10% of the winning bid.
+    expect(c.buyerPremium).toBeCloseTo(50, 2);
+  });
+
+  it("ships parcel under the freight threshold and freight above it", () => {
+    const light = computeCosts({ ...base, weightLbs: 40 });
+    expect(light.freight).toBe(0);
+    expect(light.shipping).toBeGreaterThan(0);
+
+    const heavy = computeCosts({ ...base, weightLbs: 600 });
+    expect(heavy.freight).toBeGreaterThan(0);
+    expect(heavy.shipping).toBe(0);
+  });
+
+  it("computes a positive net profit for a strong flip", () => {
+    const c = computeCosts(base);
+    expect(c.expectedNetProfit).toBeGreaterThan(0);
+    expect(c.roi).toBeGreaterThan(0);
+    expect(c.totalInvestment).toBeGreaterThan(base.winningBid);
+  });
+
+  it("recommends a max bid that back-solves to the target ROI", () => {
+    const c = computeCosts(base);
+    // Bidding exactly the recommended max should yield ~40% ROI.
+    const atMax = computeCosts({ ...base, winningBid: c.recommendedMaxBid });
+    expect(atMax.roi).toBeCloseTo(0.4, 1);
+  });
+
+  it("zeroes shipping/freight but adds pickup labor for local pickup", () => {
+    const c = computeCosts({ ...base, weightLbs: 600, localPickup: true });
+    expect(c.shipping).toBe(0);
+    expect(c.freight).toBe(0);
+    expect(c.pickupLabor).toBeGreaterThan(0);
+  });
+
+  it("applies purchase sales tax when a rate is provided", () => {
+    const taxed = computeCosts({ ...base, purchaseTaxRate: 0.0725 });
+    const untaxed = computeCosts({ ...base, purchaseTaxRate: 0 });
+    expect(taxed.salesTax).toBeGreaterThan(0);
+    expect(untaxed.salesTax).toBe(0);
+    expect(taxed.totalInvestment).toBeGreaterThan(untaxed.totalInvestment);
+  });
+});
diff --git a/src/engines/costs.ts b/src/engines/costs.ts
new file mode 100644
index 0000000..9b8ac7a
--- /dev/null
+++ b/src/engines/costs.ts
@@ -0,0 +1,162 @@
+import { round2, clamp } from "@/lib/utils";
+import {
+  BUYER_PREMIUM_RATE,
+  CONDITION_REPAIR_FACTOR,
+  LISTING_LABOR_COST,
+  MARKETPLACE_FEE_RATE,
+  PACKING_BASE,
+  PACKING_PER_LB,
+  PAYMENT_FEE_RATE,
+  PHOTOGRAPHY_COST,
+  PICKUP_LABOR_HOURLY,
+  STORAGE_PER_MONTH,
+  TARGET_ROI,
+  TESTING_COST_PER_UNIT,
+} from "./constants";
+import { estimateFreight } from "./freight";
+import type { CostBreakdown, CostInput } from "./types";
+
+/** Costs that do NOT depend on the winning bid (prep, logistics, fees). */
+function fixedCosts(input: CostInput) {
+  const qty = Math.max(1, input.quantity || 1);
+  const weight = Math.max(0, input.weightLbs || 0);
+  const totalWeight = weight; // weightLbs is the lot total in our model
+
+  const fr = estimateFreight(totalWeight);
+  const shipping = input.localPickup ? 0 : fr.parcelShipping;
+  const freight = input.localPickup ? 0 : fr.freight;
+
+  const insurance = round2(input.expectedSalePrice * 0.01);
+  const packing = round2(PACKING_BASE + PACKING_PER_LB * totalWeight);
+  const pickupLabor = input.localPickup
+    ? round2(PICKUP_LABOR_HOURLY * (1 + totalWeight / 500)) // ~1h + weight handling
+    : 0;
+  const testing = round2(TESTING_COST_PER_UNIT * qty);
+  const repairs =
+    input.repairsOverride ??
+    round2(CONDITION_REPAIR_FACTOR[input.condition] * input.expectedSalePrice);
+  // Certification only meaningful for regulated categories; default 0, overridable.
+  const certification = 0;
+  const months = Math.max(0.25, input.daysUntilSold / 30);
+  const storage = round2(STORAGE_PER_MONTH * months);
+  const photography = PHOTOGRAPHY_COST;
+  const listingLabor = LISTING_LABOR_COST;
+
+  // Resale-side fees scale with sale price, not bid.
+  const marketplaceFees = round2(input.expectedSalePrice * MARKETPLACE_FEE_RATE);
+  const paymentFees = round2(input.expectedSalePrice * PAYMENT_FEE_RATE);
+
+  const fixedTotal = round2(
+    shipping +
+      freight +
+      insurance +
+      packing +
+      pickupLabor +
+      testing +
+      repairs +
+      certification +
+      storage +
+      photography +
+      listingLabor,
+  );
+
+  return {
+    shipping,
+    freight,
+    insurance,
+    packing,
+    pickupLabor,
+    testing,
+    repairs,
+    certification,
+    storage,
+    photography,
+    listingLabor,
+    marketplaceFees,
+    paymentFees,
+    fixedTotal,
+    freightBasis: fr.basis,
+  };
+}
+
+/**
+ * Full cost + profitability breakdown for a listing at a given winning bid.
+ * Also back-solves `recommendedMaxBid`: the highest bid that still clears the
+ * TARGET_ROI threshold given all bid-independent costs and resale fees.
+ */
+export function computeCosts(input: CostInput): CostBreakdown {
+  const source = input.source;
+  const premiumRate = BUYER_PREMIUM_RATE[source] ?? 0.1;
+  const taxRate = input.purchaseTaxRate ?? 0; // resale cert typically zeroes this
+  const f = fixedCosts(input);
+
+  const bidCost = (bid: number) => {
+    const buyerPremium = round2(bid * premiumRate);
+    const salesTax = round2((bid + buyerPremium) * taxRate);
+    return { buyerPremium, salesTax, acquire: round2(bid + buyerPremium + salesTax) };
+  };
+
+  const { buyerPremium, salesTax, acquire } = bidCost(input.winningBid);
+
+  const expectedReturns = round2(
+    input.expectedSalePrice - f.marketplaceFees - f.paymentFees,
+  );
+  const totalInvestment = round2(acquire + f.fixedTotal);
+  const expectedNetProfit = round2(expectedReturns - totalInvestment);
+  const roi = totalInvestment > 0 ? round2(expectedNetProfit / totalInvestment) : 0;
+
+  // Annualize the ROI over the expected holding period.
+  const days = Math.max(1, input.daysUntilSold);
+  const annualizedReturn =
+    roi > -1
+      ? round2(Math.pow(1 + roi, 365 / days) - 1)
+      : -1;
+
+  // Recommended max bid: solve netProfit(bid) = TARGET_ROI * totalInvestment(bid).
+  // netProfit = expectedReturns - (bid*(1+prem)*(1+tax) + fixedTotal)
+  // Set roi target: (expectedReturns - acquire - fixed) / (acquire + fixed) = TARGET
+  //   => expectedReturns - acquire - fixed = TARGET*(acquire + fixed)
+  //   => expectedReturns = (1+TARGET)*(acquire + fixed)
+  //   => acquire = expectedReturns/(1+TARGET) - fixed
+  // acquire = bid*(1+prem)*(1+tax)  => bid = acquire / ((1+prem)*(1+tax))
+  const acquireBudget =
+    expectedReturns / (1 + TARGET_ROI) - f.fixedTotal;
+  const bidMultiplier = (1 + premiumRate) * (1 + taxRate);
+  const recommendedMaxBid = round2(
+    clamp(acquireBudget / bidMultiplier, 0, Number.MAX_SAFE_INTEGER),
+  );
+
+  return {
+    winningBid: round2(input.winningBid),
+    buyerPremium,
+    salesTax,
+    shipping: f.shipping,
+    freight: f.freight,
+    insurance: f.insurance,
+    packing: f.packing,
+    pickupLabor: f.pickupLabor,
+    testing: f.testing,
+    repairs: f.repairs,
+    certification: f.certification,
+    marketplaceFees: f.marketplaceFees,
+    paymentFees: f.paymentFees,
+    storage: f.storage,
+    photography: f.photography,
+    listingLabor: f.listingLabor,
+    expectedReturns,
+    totalInvestment,
+    expectedNetProfit,
+    roi,
+    annualizedReturn,
+    recommendedMaxBid,
+    assumptions: {
+      buyerPremiumRate: premiumRate,
+      purchaseTaxRate: taxRate,
+      marketplaceFeeRate: MARKETPLACE_FEE_RATE,
+      paymentFeeRate: PAYMENT_FEE_RATE,
+      targetRoi: TARGET_ROI,
+      freightBasis: f.freightBasis,
+      holdingDays: days,
+    },
+  };
+}
diff --git a/src/engines/freight.ts b/src/engines/freight.ts
new file mode 100644
index 0000000..2541002
--- /dev/null
+++ b/src/engines/freight.ts
@@ -0,0 +1,47 @@
+import { round2 } from "@/lib/utils";
+import { FREIGHT_THRESHOLD_LBS } from "./constants";
+
+export interface FreightEstimate {
+  parcelShipping: number;
+  freight: number;
+  isFreight: boolean;
+  basis: string;
+}
+
+/**
+ * Estimate outbound logistics cost from total lot weight. Light items ship as
+ * parcel (USPS/UPS ground tiers); heavy items move as LTL freight with a base
+ * charge plus per-hundredweight (CWT) rate. Deliberately simple + explainable.
+ */
+export function estimateFreight(totalWeightLbs: number): FreightEstimate {
+  const w = Math.max(0, totalWeightLbs || 0);
+
+  if (w <= FREIGHT_THRESHOLD_LBS) {
+    // Parcel tiers.
+    let parcel: number;
+    if (w <= 1) parcel = 6;
+    else if (w <= 5) parcel = 12;
+    else if (w <= 20) parcel = 22;
+    else if (w <= 50) parcel = 45;
+    else if (w <= 100) parcel = 85;
+    else parcel = 130;
+    return {
+      parcelShipping: round2(parcel),
+      freight: 0,
+      isFreight: false,
+      basis: `parcel @ ${w} lb`,
+    };
+  }
+
+  // LTL freight: base handling + per-CWT (hundredweight) rate.
+  const base = 95;
+  const perCwt = 28;
+  const cwt = w / 100;
+  const freight = round2(base + perCwt * cwt);
+  return {
+    parcelShipping: 0,
+    freight,
+    isFreight: true,
+    basis: `LTL: $${base} base + $${perCwt}/cwt × ${cwt.toFixed(2)}cwt`,
+  };
+}
diff --git a/src/engines/scoring.test.ts b/src/engines/scoring.test.ts
new file mode 100644
index 0000000..acc3bbe
--- /dev/null
+++ b/src/engines/scoring.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { computeScores, computeComponents } from "./scoring";
+import type { ScoreInput } from "./scoring";
+
+const base: ScoreInput = {
+  roi: 0.6,
+  expectedNetProfit: 3000,
+  demandScore: 70,
+  daysUntilSold: 25,
+  weightLbs: 40,
+  condition: "USED_GOOD",
+  bidCount: 2,
+  localPickup: false,
+  hasBuyerLeads: false,
+  confidenceScore: 80,
+  probabilityOfSale: 0.7,
+  isFreight: false,
+};
+
+describe("computeScores", () => {
+  it("returns all nine scoring profiles", () => {
+    const scores = computeScores(base);
+    expect(scores).toHaveLength(9);
+    const profiles = scores.map((s) => s.profile).sort();
+    expect(profiles).toContain("OVERALL_OPPORTUNITY");
+    expect(profiles).toContain("BEST_ARBITRAGE");
+    expect(profiles).toContain("PARTS_ONLY");
+  });
+
+  it("every score is 0..100 and carries a non-empty explanation", () => {
+    for (const s of computeScores(base)) {
+      expect(s.value).toBeGreaterThanOrEqual(0);
+      expect(s.value).toBeLessThanOrEqual(100);
+      expect(s.explanation.length).toBeGreaterThan(20);
+      expect(s.factors.length).toBeGreaterThan(0);
+    }
+  });
+
+  it("rewards a genuine salvage lot under PARTS_ONLY and penalizes non-parts", () => {
+    const parts = computeScores({ ...base, condition: "FOR_PARTS" }).find(
+      (s) => s.profile === "PARTS_ONLY",
+    )!;
+    const notParts = computeScores({ ...base, condition: "NEW" }).find(
+      (s) => s.profile === "PARTS_ONLY",
+    )!;
+    expect(parts.value).toBeGreaterThan(notParts.value);
+  });
+
+  it("higher ROI raises the arbitrage component and BEST_ARBITRAGE score", () => {
+    const low = computeScores({ ...base, roi: 0.05 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+    const high = computeScores({ ...base, roi: 1.5 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+    expect(high.value).toBeGreaterThan(low.value);
+  });
+
+  it("flags HIGH risk on low confidence + thin margin", () => {
+    const risky = computeScores({
+      ...base,
+      confidenceScore: 30,
+      roi: 0.05,
+      condition: "UNKNOWN",
+      daysUntilSold: 100,
+    })[0];
+    expect(risky.risk).toBe("HIGH");
+  });
+
+  it("marks light non-pickup items as EASY drop-ship, heavy freight as harder", () => {
+    const easy = computeComponents(base);
+    expect(easy.logistics).toBeGreaterThan(50);
+    const heavy = computeScores({ ...base, weightLbs: 700, isFreight: true })[0];
+    expect(["DIFFICULT", "INFEASIBLE", "MODERATE"]).toContain(heavy.dropShip);
+  });
+});
diff --git a/src/engines/scoring.ts b/src/engines/scoring.ts
new file mode 100644
index 0000000..8f45118
--- /dev/null
+++ b/src/engines/scoring.ts
@@ -0,0 +1,196 @@
+import { clamp, round2, formatPercent, formatMoney } from "@/lib/utils";
+import type {
+  ConditionKey,
+  DropShipKey,
+  ProfileScore,
+  RiskKey,
+  ScoreComponents,
+  ScoreProfileKey,
+} from "./types";
+
+export interface ScoreInput {
+  roi: number;
+  expectedNetProfit: number;
+  demandScore: number; // 0..100
+  daysUntilSold: number;
+  weightLbs: number;
+  condition: ConditionKey;
+  bidCount: number;
+  localPickup: boolean;
+  hasBuyerLeads: boolean;
+  confidenceScore: number; // 0..100
+  probabilityOfSale: number; // 0..1
+  isFreight: boolean;
+}
+
+const CONDITION_SCORE: Record<ConditionKey, number> = {
+  NEW: 95,
+  LIKE_NEW: 82,
+  USED_GOOD: 65,
+  USED_FAIR: 45,
+  FOR_PARTS: 22,
+  UNKNOWN: 40,
+};
+
+/** Map an ROI ratio onto a 0..100 arbitrage score with diminishing returns. */
+function arbitrageFromRoi(roi: number): number {
+  // roi -0.5 -> ~5, 0 -> 35, 0.4 -> 70, 1.0 -> 88, 2.0+ -> ~98
+  const s = 35 + 55 * (1 - Math.exp(-1.15 * Math.max(0, roi))) + (roi < 0 ? roi * 60 : 0);
+  return clamp(s, 0, 100);
+}
+
+/** Compute the seven component sub-scores. */
+export function computeComponents(input: ScoreInput): ScoreComponents {
+  const arbitrage = round2(arbitrageFromRoi(input.roi));
+  const demand = round2(clamp(input.demandScore, 0, 100));
+  const velocity = round2(clamp(100 - input.daysUntilSold * 0.72, 0, 100));
+
+  // Logistics: lighter + parcel-shippable is best; freight and heavy items hurt.
+  let logistics = 100 - Math.min(input.weightLbs, 1000) * 0.06;
+  if (input.isFreight) logistics -= 25;
+  if (input.localPickup) logistics -= 10; // pickup adds friction unless local buyer
+  logistics = round2(clamp(logistics, 0, 100));
+
+  const condition = CONDITION_SCORE[input.condition] ?? 40;
+  const competition = round2(clamp(95 - input.bidCount * 6, 5, 100));
+  const buyer = round2(
+    clamp(input.probabilityOfSale * 70 + (input.hasBuyerLeads ? 25 : 0) + input.demandScore * 0.1, 0, 100),
+  );
+
+  return { arbitrage, demand, velocity, logistics, condition, competition, buyer };
+}
+
+export function computeRisk(input: ScoreInput, c: ScoreComponents): RiskKey {
+  let risk = 0;
+  if (input.confidenceScore < 45) risk += 2;
+  else if (input.confidenceScore < 70) risk += 1;
+  if (input.roi < 0.15) risk += 2;
+  else if (input.roi < 0.35) risk += 1;
+  if (input.condition === "FOR_PARTS" || input.condition === "UNKNOWN") risk += 1;
+  if (c.velocity < 40) risk += 1;
+  if (risk >= 4) return "HIGH";
+  if (risk >= 2) return "MEDIUM";
+  return "LOW";
+}
+
+export function computeDropShip(input: ScoreInput): DropShipKey {
+  // Drop-ship feasibility: can this move seller -> buyer without you touching it?
+  if (input.localPickup && input.weightLbs > 300) return "INFEASIBLE";
+  if (input.isFreight) return input.weightLbs > 500 ? "DIFFICULT" : "MODERATE";
+  if (input.weightLbs <= 50 && !input.localPickup) return "EASY";
+  if (input.weightLbs <= 150) return "MODERATE";
+  return "DIFFICULT";
+}
+
+// Per-profile component weights. Each row sums to ~1 across the 7 components.
+const PROFILE_WEIGHTS: Record<
+  ScoreProfileKey,
+  Partial<ScoreComponents> & { _confidence?: number }
+> = {
+  OVERALL_OPPORTUNITY: { arbitrage: 0.28, demand: 0.18, velocity: 0.14, logistics: 0.12, condition: 0.1, competition: 0.1, buyer: 0.08 },
+  BEST_ARBITRAGE: { arbitrage: 0.5, demand: 0.2, competition: 0.15, condition: 0.15 },
+  QUICK_FLIP: { velocity: 0.4, demand: 0.25, logistics: 0.2, arbitrage: 0.15 },
+  COLLECTOR: { demand: 0.4, condition: 0.35, arbitrage: 0.25 },
+  LOCAL_PICKUP: { arbitrage: 0.4, demand: 0.25, condition: 0.2, competition: 0.15 },
+  EASY_FREIGHT: { logistics: 0.45, arbitrage: 0.3, demand: 0.25 },
+  PARTS_ONLY: { arbitrage: 0.55, demand: 0.25, competition: 0.2 },
+  HIGH_CONFIDENCE: { arbitrage: 0.3, demand: 0.2, condition: 0.2, buyer: 0.15, velocity: 0.15 },
+  HIGH_PROFIT: { arbitrage: 0.45, demand: 0.3, velocity: 0.15, logistics: 0.1 },
+};
+
+const PROFILE_LABEL: Record<ScoreProfileKey, string> = {
+  OVERALL_OPPORTUNITY: "Overall Opportunity",
+  BEST_ARBITRAGE: "Best Arbitrage",
+  QUICK_FLIP: "Quick Flip",
+  COLLECTOR: "Collector",
+  LOCAL_PICKUP: "Local Pickup",
+  EASY_FREIGHT: "Easy Freight",
+  PARTS_ONLY: "Parts Only",
+  HIGH_CONFIDENCE: "High Confidence",
+  HIGH_PROFIT: "High Profit",
+};
+
+const COMPONENT_LABEL: Record<keyof ScoreComponents, string> = {
+  arbitrage: "arbitrage margin",
+  demand: "market demand",
+  velocity: "sale velocity",
+  logistics: "logistics ease",
+  condition: "item condition",
+  competition: "low competition",
+  buyer: "buyer readiness",
+};
+
+function scoreProfile(
+  profile: ScoreProfileKey,
+  c: ScoreComponents,
+  input: ScoreInput,
+  risk: RiskKey,
+  dropShip: DropShipKey,
+): ProfileScore {
+  const weights = PROFILE_WEIGHTS[profile];
+  const factors: ProfileScore["factors"] = [];
+  let value = 0;
+
+  for (const [key, w] of Object.entries(weights) as [keyof ScoreComponents, number][]) {
+    if (key === ("_confidence" as keyof ScoreComponents)) continue;
+    const comp = c[key] ?? 0;
+    const contribution = round2(comp * w);
+    value += contribution;
+    factors.push({ label: COMPONENT_LABEL[key], weight: w, contribution });
+  }
+
+  // Profile-specific adjustments beyond the weighted base.
+  let adjustment = 0;
+  const notes: string[] = [];
+  if (profile === "HIGH_CONFIDENCE") {
+    adjustment = (input.confidenceScore - 50) * 0.3;
+    notes.push(`confidence ${input.confidenceScore.toFixed(0)}/100`);
+  }
+  if (profile === "PARTS_ONLY" && input.condition !== "FOR_PARTS") {
+    adjustment -= 20; // parts-only profile penalizes non-parts items
+    notes.push("not a parts/salvage lot");
+  }
+  if (profile === "PARTS_ONLY" && input.condition === "FOR_PARTS") {
+    adjustment += 12;
+    notes.push("genuine salvage lot");
+  }
+  if (profile === "LOCAL_PICKUP") {
+    adjustment += input.localPickup ? 8 : -12;
+    notes.push(input.localPickup ? "local pickup available" : "no local pickup");
+  }
+  if (profile === "EASY_FREIGHT" && input.isFreight) {
+    adjustment -= 15;
+    notes.push("ships as LTL freight");
+  }
+  if (profile === "HIGH_PROFIT") {
+    // Reward large absolute dollars, not just ratio.
+    adjustment += clamp(input.expectedNetProfit / 250, -10, 20);
+    notes.push(`${formatMoney(input.expectedNetProfit)} est. net`);
+  }
+
+  value = round2(clamp(value + adjustment, 0, 100));
+
+  // Build the "why" explanation from the top contributors.
+  const top = [...factors].sort((a, b) => b.contribution - a.contribution).slice(0, 3);
+  const drivers = top
+    .map((f) => `${f.label} (${f.contribution.toFixed(0)} pts)`)
+    .join(", ");
+  const roiPhrase = `ROI ${formatPercent(input.roi)}`;
+  const extra = notes.length ? ` Adjustments: ${notes.join("; ")}.` : "";
+  const explanation =
+    `${PROFILE_LABEL[profile]} scored ${value.toFixed(0)}/100. ` +
+    `Top drivers: ${drivers}. ${roiPhrase}, ${input.daysUntilSold}d to sell, ` +
+    `risk ${risk}, drop-ship ${dropShip}.${extra}`;
+
+  return { profile, value, components: c, risk, dropShip, explanation, factors };
+}
+
+/** Compute all nine profile scores for a listing. */
+export function computeScores(input: ScoreInput): ProfileScore[] {
+  const c = computeComponents(input);
+  const risk = computeRisk(input, c);
+  const dropShip = computeDropShip(input);
+  return (Object.keys(PROFILE_WEIGHTS) as ScoreProfileKey[]).map((p) =>
+    scoreProfile(p, c, input, risk, dropShip),
+  );
+}
diff --git a/src/engines/types.ts b/src/engines/types.ts
new file mode 100644
index 0000000..e9b95c5
--- /dev/null
+++ b/src/engines/types.ts
@@ -0,0 +1,133 @@
+// Shared engine types. Engines operate on plain numbers (US dollars) so they
+// are pure, deterministic, and unit-testable independent of Prisma/Decimal.
+
+export type ConditionKey =
+  | "NEW"
+  | "LIKE_NEW"
+  | "USED_GOOD"
+  | "USED_FAIR"
+  | "FOR_PARTS"
+  | "UNKNOWN";
+
+export type SourceKey =
+  | "GOVDEALS"
+  | "PUBLIC_SURPLUS"
+  | "GSA_AUCTIONS"
+  | "COUNTY"
+  | "STATE_SURPLUS"
+  | "UNIVERSITY_SURPLUS"
+  | "MUNICIBID"
+  | "BID4ASSETS"
+  | "CSV"
+  | "EXTENSION"
+  | "OTHER";
+
+export type RiskKey = "LOW" | "MEDIUM" | "HIGH";
+export type DropShipKey = "EASY" | "MODERATE" | "DIFFICULT" | "INFEASIBLE";
+
+export type ScoreProfileKey =
+  | "OVERALL_OPPORTUNITY"
+  | "BEST_ARBITRAGE"
+  | "QUICK_FLIP"
+  | "COLLECTOR"
+  | "LOCAL_PICKUP"
+  | "EASY_FREIGHT"
+  | "PARTS_ONLY"
+  | "HIGH_CONFIDENCE"
+  | "HIGH_PROFIT";
+
+/** Inputs the valuation engine needs (anchor + item facts). */
+export interface ValuationInput {
+  /** Best estimate of new retail price; the anchor for all derived values. */
+  newRetail: number;
+  condition: ConditionKey;
+  quantity: number;
+  /** 0..100 demand for this category/brand (from research/AI or a default). */
+  demandScore: number;
+  /** How well the item was identified (0..1); drives confidence. */
+  identificationConfidence: number;
+  /** Count of real comparable sales found; drives confidence. */
+  comparableCount?: number;
+}
+
+export interface Valuation {
+  newRetail: number;
+  newReplacement: number;
+  avgRetail: number;
+  usedSoldPrice: number;
+  usedAskingPrice: number;
+  usedLow: number;
+  usedHigh: number;
+  wholesaleValue: number;
+  liquidationValue: number;
+  sellTodayValue: number;
+  value7Day: number;
+  value30Day: number;
+  value90Day: number;
+  expectedSalePrice: number;
+  probabilityOfSale: number; // 0..1
+  daysUntilSold: number;
+  confidenceScore: number; // 0..100
+}
+
+export interface CostInput {
+  source: SourceKey;
+  winningBid: number;
+  quantity: number;
+  weightLbs: number;
+  condition: ConditionKey;
+  expectedSalePrice: number;
+  daysUntilSold: number;
+  /** true when the item must be picked up in person (no shipping from seller). */
+  localPickup?: boolean;
+  /** destination sales-tax rate applied to the purchase (resale certs may zero this). */
+  purchaseTaxRate?: number;
+  /** repair estimate override (else derived from condition). */
+  repairsOverride?: number;
+}
+
+export interface CostBreakdown {
+  winningBid: number;
+  buyerPremium: number;
+  salesTax: number;
+  shipping: number;
+  freight: number;
+  insurance: number;
+  packing: number;
+  pickupLabor: number;
+  testing: number;
+  repairs: number;
+  certification: number;
+  marketplaceFees: number;
+  paymentFees: number;
+  storage: number;
+  photography: number;
+  listingLabor: number;
+  expectedReturns: number;
+  totalInvestment: number;
+  expectedNetProfit: number;
+  roi: number;
+  annualizedReturn: number;
+  recommendedMaxBid: number;
+  assumptions: Record<string, string | number>;
+}
+
+export interface ScoreComponents {
+  arbitrage: number;
+  demand: number;
+  velocity: number;
+  logistics: number;
+  condition: number;
+  competition: number;
+  buyer: number;
+}
+
+export interface ProfileScore {
+  profile: ScoreProfileKey;
+  value: number;
+  components: ScoreComponents;
+  risk: RiskKey;
+  dropShip: DropShipKey;
+  explanation: string;
+  factors: { label: string; weight: number; contribution: number }[];
+}
diff --git a/src/engines/valuation.test.ts b/src/engines/valuation.test.ts
new file mode 100644
index 0000000..a8af7ee
--- /dev/null
+++ b/src/engines/valuation.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { computeValuation } from "./valuation";
+
+describe("computeValuation", () => {
+  it("derives a full valuation ladder from a retail anchor", () => {
+    const v = computeValuation({
+      newRetail: 20000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 70,
+      identificationConfidence: 0.9,
+      comparableCount: 6,
+    });
+
+    // Used-good sits at ~46% of retail.
+    expect(v.usedSoldPrice).toBeCloseTo(9200, 0);
+    expect(v.usedAskingPrice).toBeGreaterThan(v.usedSoldPrice);
+    expect(v.usedLow).toBeLessThan(v.usedSoldPrice);
+    expect(v.usedHigh).toBeGreaterThan(v.usedSoldPrice);
+    // Channel ordering: liquidation < wholesale < used.
+    expect(v.liquidationValue).toBeLessThan(v.wholesaleValue);
+    expect(v.wholesaleValue).toBeLessThan(v.usedSoldPrice);
+    // Longer horizon fetches more than a fire sale.
+    expect(v.value90Day).toBeGreaterThan(v.sellTodayValue);
+    expect(v.expectedSalePrice).toBeGreaterThan(0);
+  });
+
+  it("scales expected sale price by quantity", () => {
+    const one = computeValuation({
+      newRetail: 500,
+      condition: "LIKE_NEW",
+      quantity: 1,
+      demandScore: 50,
+      identificationConfidence: 0.6,
+    });
+    const ten = computeValuation({
+      newRetail: 500,
+      condition: "LIKE_NEW",
+      quantity: 10,
+      demandScore: 50,
+      identificationConfidence: 0.6,
+    });
+    expect(ten.expectedSalePrice).toBeCloseTo(one.expectedSalePrice * 10, 0);
+  });
+
+  it("gives higher confidence with more comparables and better ID", () => {
+    const weak = computeValuation({
+      newRetail: 1000,
+      condition: "UNKNOWN",
+      quantity: 1,
+      demandScore: 40,
+      identificationConfidence: 0.2,
+      comparableCount: 0,
+    });
+    const strong = computeValuation({
+      newRetail: 1000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 40,
+      identificationConfidence: 0.95,
+      comparableCount: 8,
+    });
+    expect(strong.confidenceScore).toBeGreaterThan(weak.confidenceScore);
+    expect(strong.confidenceScore).toBeLessThanOrEqual(100);
+  });
+
+  it("rewards high demand with faster sale and higher probability", () => {
+    const lowDemand = computeValuation({
+      newRetail: 1000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 10,
+      identificationConfidence: 0.7,
+    });
+    const highDemand = computeValuation({
+      newRetail: 1000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 95,
+      identificationConfidence: 0.7,
+    });
+    expect(highDemand.daysUntilSold).toBeLessThan(lowDemand.daysUntilSold);
+    expect(highDemand.probabilityOfSale).toBeGreaterThan(lowDemand.probabilityOfSale);
+  });
+});
diff --git a/src/engines/valuation.ts b/src/engines/valuation.ts
new file mode 100644
index 0000000..1f80eaf
--- /dev/null
+++ b/src/engines/valuation.ts
@@ -0,0 +1,84 @@
+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,
+  };
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..84941e8
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,13 @@
+import { PrismaClient } from "@prisma/client";
+
+// Reuse a single PrismaClient across hot-reloads in dev to avoid exhausting
+// Postgres connections (Next.js re-evaluates modules on every change).
+const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
+
+export const prisma =
+  globalForPrisma.prisma ??
+  new PrismaClient({
+    log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
+  });
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..d39b59b
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,50 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/** shadcn/ui-style className combiner. */
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}
+
+/** Round to whole cents (avoids float drift; all engine money is in dollars). */
+export function round2(n: number): number {
+  return Math.round((n + Number.EPSILON) * 100) / 100;
+}
+
+export function clamp(n: number, lo: number, hi: number): number {
+  return Math.min(hi, Math.max(lo, n));
+}
+
+const usd = new Intl.NumberFormat("en-US", {
+  style: "currency",
+  currency: "USD",
+  maximumFractionDigits: 0,
+});
+const usdCents = new Intl.NumberFormat("en-US", {
+  style: "currency",
+  currency: "USD",
+});
+
+export function formatMoney(n: number | null | undefined, cents = false): string {
+  if (n === null || n === undefined || Number.isNaN(n)) return "—";
+  return cents ? usdCents.format(n) : usd.format(n);
+}
+
+export function formatPercent(ratio: number | null | undefined, digits = 0): string {
+  if (ratio === null || ratio === undefined || Number.isNaN(ratio)) return "—";
+  return `${(ratio * 100).toFixed(digits)}%`;
+}
+
+/** Human countdown to a future date, e.g. "2d 4h" or "Closed". */
+export function countdown(to: Date | string | null | undefined, now = new Date()): string {
+  if (!to) return "—";
+  const target = typeof to === "string" ? new Date(to) : to;
+  const ms = target.getTime() - now.getTime();
+  if (ms <= 0) return "Closed";
+  const d = Math.floor(ms / 86_400_000);
+  const h = Math.floor((ms % 86_400_000) / 3_600_000);
+  const m = Math.floor((ms % 3_600_000) / 60_000);
+  if (d > 0) return `${d}d ${h}h`;
+  if (h > 0) return `${h}h ${m}m`;
+  return `${m}m`;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..3dbf34f
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vitest/config";
+import { fileURLToPath } from "node:url";
+
+export default defineConfig({
+  resolve: {
+    alias: {
+      "@": fileURLToPath(new URL("./src", import.meta.url)),
+    },
+  },
+  test: {
+    environment: "node",
+    include: ["src/**/*.test.ts", "tests/unit/**/*.test.ts"],
+    globals: true,
+  },
+});

← d537d06 Phase 1: Next.js 16 + TS + Prisma 6 + Postgres scaffold, ful  ·  back to Govarbitrage  ·  Phase 3: local-Ollama AI identification, research pipeline, 23bcc51 →