/** * Auction lot assessment job (§33–§35): converts estimates, current bids and hammer prices to USD, * adds the house's buyer premium (lots are hammer-basis) and compares the buyer-pays figure with the * RIV of the lot's own variant through the same gates as marketplace asks. Writes the result on the * lot (all_in_*, bid_vs_riv, estimate_vs_riv, assessment_verdict). Never writes an ungated / * anomalous / review discount into bid_vs_riv. */ import { sql } from 'drizzle-orm'; import { logger } from '@rareindex/shared'; import { assessLot, feeScheduleFor } from '@rareindex/valuation'; import { db } from '../lib/db.ts'; import { usdRateFor } from '../lib/fx.ts'; const log = logger.child({ component: 'auctions-assess' }); interface LotRow { id: string; asset_id: string | null; variant_id: string | null; grader: string | null; grade: string | null; currency: string | null; estimate_low: number | null; estimate_high: number | null; current_bid: number | null; hammer_price: number | null; bid_count: number | null; status: string; auction_house: string | null; source_id: string; // valuation of the lot's own variant (variant_stats) when the variant is known v_riv: number | null; v_conf: number | null; v_n: number | null; v_method: string | null; // asset-level fallback a_riv: number | null; a_conf: number | null; a_n: number | null; a_method: string | null; rep_grader: string | null; rep_grade: string | null; } export interface AssessResult { scanned: number; assessed: number; verdicts: Record; fxMissing: number; } const basisOf = (method: string | null): 'transactions' | 'comps' | 'guide' | 'none' | null => { const m = method?.split(':')[1]; return m === 'transactions' || m === 'comps' || m === 'guide' || m === 'none' ? m : null; }; export async function assessLots(opts: { limit?: number; all?: boolean; now?: Date } = {}): Promise { const now = opts.now ?? new Date(); const limit = opts.limit ?? 20_000; const res: AssessResult = { scanned: 0, assessed: 0, verdicts: {}, fxMissing: 0 }; const rows = (await db().execute(sql` select l.id, l.asset_id, l.variant_id, l.grader, l.grade, l.currency, l.estimate_low::float as estimate_low, l.estimate_high::float as estimate_high, l.current_bid::float as current_bid, l.hammer_price::float as hammer_price, l.bid_count, l.status, au.auction_house, l.source_id, vs.riv_usd::float as v_riv, vs.riv_confidence::float as v_conf, vs.riv_sample_size as v_n, (select v.method from valuations v where v.variant_id = l.variant_id order by v.computed_at desc limit 1) as v_method, st.riv_usd::float as a_riv, st.riv_confidence::float as a_conf, st.riv_sample_size as a_n, (select v.method from valuations v where v.variant_id = st.riv_variant_id order by v.computed_at desc limit 1) as a_method, rv.grader as rep_grader, rv.grade as rep_grade from auction_lots l join auctions au on au.id = l.auction_id left join variant_stats vs on vs.variant_id = l.variant_id left join asset_stats st on st.asset_id = l.asset_id left join asset_variants rv on rv.id = st.riv_variant_id where l.asset_id is not null and ((l.status in ('live','upcoming') and (l.ends_at is null or l.ends_at > now() - interval '1 day')) or (l.status = 'ended' and l.hammer_price is not null)) ${opts.all ? sql`` : sql`and (l.assessed_at is null or l.assessed_at < l.updated_at or l.assessed_at < now() - interval '6 hours')`} order by l.ends_at asc nulls last limit ${limit}`)) as unknown as LotRow[]; res.scanned = rows.length; for (const r of rows) { const currency = r.currency ?? 'USD'; const fx = await usdRateFor(currency, now); if (!fx || fx.rate <= 0) { res.fxMissing++; continue; } const toUsd = (v: number | null) => (v === null || v === undefined ? null : v / fx.rate); const schedule = feeScheduleFor(r.auction_house) ?? feeScheduleFor(r.source_id); let fxUsdToScheduleCurrency = 1; if (schedule && schedule.currency !== 'USD') { const sr = await usdRateFor(schedule.currency, now); if (sr && sr.rate > 0) fxUsdToScheduleCurrency = sr.rate; } // Which valuation is comparable: the lot's own variant; else the asset headline only when the // lot is raw (no grader) and the representative variant is raw too, or grades match. let riv: Parameters[0]['riv'] = null; let sameVariant: boolean | undefined = undefined; if (r.variant_id && r.v_riv !== null) riv = { rivUsd: r.v_riv, confidence: r.v_conf, sampleSize: r.v_n, basis: basisOf(r.v_method) ?? (Number(r.v_n ?? 0) >= 5 ? 'transactions' : null) }; else if (r.a_riv !== null) { const lotRaw = !r.grader || r.grader === 'raw'; const repRaw = !r.rep_grader; sameVariant = (lotRaw && repRaw) || (!lotRaw && r.grader === r.rep_grader && (r.grade ?? null) === (r.rep_grade ?? null)); riv = { rivUsd: r.a_riv, confidence: r.a_conf, sampleSize: r.a_n, basis: basisOf(r.a_method) ?? (Number(r.a_n ?? 0) >= 5 ? 'transactions' : null) }; } const estLowUsd = toUsd(r.estimate_low); const estHighUsd = toUsd(r.estimate_high); const bidUsd = toUsd(r.current_bid); const hammerUsd = toUsd(r.hammer_price); const a = assessLot({ house: schedule?.id ?? r.auction_house ?? r.source_id, currentBidUsd: bidUsd, bidCount: r.bid_count, estimateLowUsd: estLowUsd, estimateHighUsd: estHighUsd, hammerPriceUsd: hammerUsd, buyerPremiumIncluded: false, fxUsdToScheduleCurrency, riv, sameVariant }); await db().execute(sql` update auction_lots set estimate_low_usd = ${estLowUsd}, estimate_high_usd = ${estHighUsd}, current_bid_usd = ${bidUsd}, hammer_price_usd = ${hammerUsd}, fx_rate = ${fx.rate}, fx_date = ${fx.date}::date, buyer_premium_rate = ${a.buyerPremiumRate}, fee_basis = ${a.feeBasis}, all_in_bid_usd = ${a.allInBidUsd}, all_in_estimate_low_usd = ${a.allInEstimateLowUsd}, all_in_estimate_high_usd = ${a.allInEstimateHighUsd}, riv_usd_at_assessment = ${a.rivUsd}, bid_vs_riv = ${a.bidVsRiv}, estimate_vs_riv = ${a.estimateVsRiv}, assessment_verdict = ${a.verdict}, assessed_at = ${now.toISOString()}::timestamptz where id = ${r.id}`); // postgres.js needs an ISO string for a Date parameter in raw sql res.assessed++; res.verdicts[a.verdict] = (res.verdicts[a.verdict] ?? 0) + 1; } log.info(res, 'auction lots assessed'); return res; }