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