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