← back to Govarbitrage
src/engines/demand.ts
74 lines
import { clamp } from "@/lib/utils";
// Deterministic, $0 demand + retail heuristic. Used as the fallback when no AI
// model is reachable, and as a sanity floor for AI estimates. Keyword-driven so
// it is fully explainable.
interface CategorySignal {
demand: number; // 0..100 baseline secondary-market liquidity
retailFloor: number; // rough per-unit new-retail baseline (USD)
keywords: string[];
}
const CATEGORY_SIGNALS: CategorySignal[] = [
{ demand: 82, retailFloor: 900, keywords: ["laptop", "macbook", "thinkpad", "computer", "workstation"] },
{ demand: 80, retailFloor: 600, keywords: ["iphone", "ipad", "tablet", "phone", "surface"] },
{ demand: 70, retailFloor: 400, keywords: ["monitor", "display", "projector", "tv", "television"] },
{ demand: 74, retailFloor: 4500, keywords: ["dental", "medical", "surgical", "exam", "patient", "dental chair", "autoclave"] },
{ demand: 68, retailFloor: 1200, keywords: ["microscope", "lab", "laboratory", "analyzer", "centrifuge", "spectrometer"] },
{ demand: 66, retailFloor: 2200, keywords: ["forklift", "generator", "compressor", "welder", "lathe", "cnc"] },
{ demand: 60, retailFloor: 800, keywords: ["herman miller", "aeron", "steelcase", "office chair", "ergonomic"] },
{ demand: 45, retailFloor: 300, keywords: ["desk", "cabinet", "table", "furniture", "shelving", "credenza"] },
{ demand: 72, retailFloor: 550, keywords: ["drone", "camera", "lens", "nikon", "canon", "sony", "gopro"] },
{ demand: 64, retailFloor: 700, keywords: ["radio", "motorola", "network", "cisco", "switch", "router", "server"] },
{ demand: 58, retailFloor: 500, keywords: ["tool", "dewalt", "milwaukee", "makita", "power tool", "generator"] },
{ demand: 50, retailFloor: 250, keywords: ["vehicle", "truck", "trailer", "atv", "mower", "tractor"] },
];
const PREMIUM_BRANDS = [
"herman miller",
"steelcase",
"apple",
"macbook",
"dewalt",
"milwaukee",
"cisco",
"midmark",
"adec",
"leica",
"nikon",
];
export interface DemandEstimate {
demandScore: number;
retailFloor: number;
matchedCategory: string | null;
brandBoost: boolean;
}
/** Estimate demand + a retail floor from listing text keywords. */
export function estimateDemand(text: string): DemandEstimate {
const hay = text.toLowerCase();
let best: CategorySignal | null = null;
let bestHits = 0;
for (const sig of CATEGORY_SIGNALS) {
const hits = sig.keywords.filter((k) => hay.includes(k)).length;
if (hits > bestHits) {
bestHits = hits;
best = sig;
}
}
const brandBoost = PREMIUM_BRANDS.some((b) => hay.includes(b));
const baseDemand = best ? best.demand : 40;
const demandScore = clamp(baseDemand + (brandBoost ? 8 : 0), 0, 100);
return {
demandScore,
retailFloor: best ? best.retailFloor : 200,
matchedCategory: best ? best.keywords[0] : null,
brandBoost,
};
}