← back to Govarbitrage

src/lib/hot-deals.ts

192 lines

import { prisma } from "@/lib/db";

// A HOT DEAL is not merely a cheap lot — it is a lot where BOTH sides check out:
//   BUY side  — there is still headroom to bid profitably (recommendedMaxBid is
//               meaningfully above the current bid), the net profit and ROI clear
//               the bar, and the auction is still open (or a make-offer item).
//   SELL side — "real opportunity to sell before you buy": genuine resale demand
//               (demand score + probability of sale) and a valued expected sale.
// This is the gate behind the 🔥 alert. Thresholds are env-tunable so Steve can
// dial the signal without a code change.

export interface HotDealCriteria {
  minScore: number; // OVERALL_OPPORTUNITY value 0..100
  minArbitrage: number; // arbitrage sub-score
  minDemand: number; // demand sub-score (sell-side proof)
  minProbSale: number; // Research.probabilityOfSale 0..1
  minNetProfit: number; // $ expected net profit
  minRoi: number; // ratio, 0.5 = 50%
  minHeadroomPct: number; // (recMaxBid - currentBid)/recMaxBid, room to still bid & profit
}

function envNum(key: string, dflt: number): number {
  const v = process.env[key];
  const n = v == null ? NaN : Number(v);
  return Number.isFinite(n) ? n : dflt;
}

export function defaultCriteria(): HotDealCriteria {
  // Tuned 2026-07-10 against the live corpus: the Overall score is bimodal —
  // a loose 75-81 cluster (~9% of all listings, inflated by the heuristic,
  // no-AI valuation engine) and a small genuine elite at 82+. Anchoring at
  // score≥82 surfaces only true standouts instead of flooding. Every threshold
  // is env-overridable so Steve can loosen/tighten without a code change.
  // NOTE: valuations are heuristic (optimistic) until real marketplace-comp
  // calibration lands — treat ROI as directional, verify comps before bidding.
  return {
    minScore: envNum("HOT_MIN_SCORE", 82),
    minArbitrage: envNum("HOT_MIN_ARBITRAGE", 72),
    minDemand: envNum("HOT_MIN_DEMAND", 55),
    minProbSale: envNum("HOT_MIN_PROB_SALE", 0.55),
    minNetProfit: envNum("HOT_MIN_NET", 300),
    minRoi: envNum("HOT_MIN_ROI", 0.75),
    minHeadroomPct: envNum("HOT_MIN_HEADROOM_PCT", 0.2),
  };
}

export type HotDeal = Awaited<ReturnType<typeof findHotDeals>>[number];

const num = (d: unknown): number => (d == null ? 0 : Number(d));

// ── HONESTY GATE ──────────────────────────────────────────────────────────
// The valuation engine is heuristic and runs OPTIMISTIC (a "727% ROI" is not a
// promise we can keep). Charging for alerts built on inflated ROI is a
// chargeback/trust trap. So every alert is discounted by our CONFIDENCE in it
// and always carries the evidence basis + a verify-comps disclaimer. We sell
// "curated + de-duped + early", never a guaranteed number. Under-promise.
export type Confidence = "LOW" | "MEDIUM" | "HIGH";

// Confidence from REAL evidence: how many comparable sales back the estimate,
// the modeled probability of sale, and whether we have used-market comps at all.
export function assessConfidence(
  comparableCount: number,
  probSale: number,
  hasUsedComps: boolean,
): Confidence {
  if (comparableCount >= 5 && probSale >= 0.7) return "HIGH";
  if (comparableCount >= 2 && probSale >= 0.55 && hasUsedComps) return "MEDIUM";
  return "LOW";
}

// Haircut applied to the optimistic sale/ROI by confidence — the number we
// actually SHOW is the conservative low band, so reality tends to beat it.
export const CONFIDENCE_HAIRCUT: Record<Confidence, number> = {
  LOW: 0.5,
  MEDIUM: 0.7,
  HIGH: 0.9,
};

// Displayed ROI is capped so a heuristic outlier can never render as hype.
export const ROI_DISPLAY_CAP = 2.0; // 200%

/**
 * Find ACTIVE listings that pass the HOT DEAL gate. If `onlyUnalerted`, skip
 * ones already alerted (hotAlertedAt set) — the alerter uses this to notify
 * each deal exactly once.
 */
export async function findHotDeals(opts: {
  criteria?: HotDealCriteria;
  onlyUnalerted?: boolean;
  limit?: number;
} = {}) {
  const c = opts.criteria ?? defaultCriteria();
  const now = new Date();

  // Pull ACTIVE listings with a strong overall score, then apply the full gate
  // in JS (mixes Score + CostBreakdown + Research fields across relations).
  const listings = await prisma.listing.findMany({
    where: {
      listingStatus: "ACTIVE",
      ...(opts.onlyUnalerted ? { hotAlertedAt: null } : {}),
      scores: { some: { profile: "OVERALL_OPPORTUNITY", value: { gte: c.minScore } } },
    },
    include: {
      research: true,
      costBreakdown: true,
      scores: true,
      buyerLeads: true,
      buyerPage: true,
      comparables: true,
    },
  });

  const deals = [];
  for (const l of listings) {
    const s = l.scores.find((x) => x.profile === "OVERALL_OPPORTUNITY");
    const cb = l.costBreakdown;
    const r = l.research;
    if (!s || !cb) continue;

    const currentBid = num(l.currentBid);
    const recMax = num(cb.recommendedMaxBid);
    const netProfit = num(cb.expectedNetProfit);
    const roi = num(cb.roi);
    const probSale = num(r?.probabilityOfSale);

    // Auction must still be live: either open (closingAt in the future) or a
    // make-offer/no-deadline item (null closingAt).
    const open = !l.closingAt || new Date(l.closingAt).getTime() > now.getTime();
    if (!open) continue;

    // BUY-side headroom: recommended max bid must sit above the current bid by
    // at least minHeadroomPct — i.e. you can still bid and profit.
    const headroom = recMax > 0 ? (recMax - currentBid) / recMax : 0;

    const pass =
      s.value >= c.minScore &&
      s.arbitrage >= c.minArbitrage &&
      s.demand >= c.minDemand &&
      probSale >= c.minProbSale &&
      netProfit >= c.minNetProfit &&
      roi >= c.minRoi &&
      recMax > currentBid &&
      headroom >= c.minHeadroomPct;

    if (!pass) continue;

    // ── Honesty Gate: discount the optimistic estimate by our confidence ──
    const comparableCount = l.comparables.length;
    const hasUsedComps = r?.usedLow != null || r?.usedSoldPrice != null || r?.usedHigh != null;
    const confidence = assessConfidence(comparableCount, probSale, hasUsedComps);
    const haircut = CONFIDENCE_HAIRCUT[confidence];
    const expectedSale = num(r?.expectedSalePrice);
    const expectedSaleLow = Math.round(expectedSale * haircut);
    // Conservative ROI we actually SHOW: haircut by confidence, then capped.
    const roiConservative = Math.min(roi * haircut, ROI_DISPLAY_CAP);
    const roiCapped = roi > ROI_DISPLAY_CAP; // flag when the raw number was hype
    const disclaimer = `Heuristic estimate${comparableCount ? ` from ${comparableCount} comp${comparableCount === 1 ? "" : "s"}` : " (thin comp data)"} — verify before bidding.`;

    deals.push({
      listing: l,
      score: s,
      cost: cb,
      research: r,
      currentBid,
      recMax,
      headroom,
      netProfit,
      roi,
      probSale,
      expectedSale,
      // Honesty Gate outputs (what the paid alert should DISPLAY):
      confidence,
      expectedSaleLow,
      roiConservative,
      roiCapped,
      disclaimer,
      demandScore: s.demand,
      comparableCount,
      buyerLeadCount: l.buyerLeads.length,
      buyerLeadTop: l.buyerLeads
        .map((b) => num(b.offer))
        .filter((x) => x > 0)
        .sort((a, b) => b - a)[0] ?? 0,
      closingAt: l.closingAt,
    });
  }

  // Best first: highest score, then net profit.
  deals.sort((a, b) => b.score.value - a.score.value || b.netProfit - a.netProfit);
  return typeof opts.limit === "number" ? deals.slice(0, opts.limit) : deals;
}