← back to Govarbitrage

src/engines/scoring.ts

197 lines

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