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