Ask vs RIV: −50 % review threshold (§174) — deeper discounts are flagged riv_review and excluded from deal rails, screener preset, Deal Radar, digest and Rare Radar; 'Needs review' label
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 changed files +46 −17
modified
apps/web/src/app/methodology/page.tsx
+2 −1
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import type { ReactNode } from 'react'; |
| 4 | −import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_THRESHOLD, MAX_PLAUSIBLE_CHANGE, PREMIUM_THRESHOLD } from '@rareindex/valuation'; | |
| 4 | +import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_REVIEW_THRESHOLD, DEAL_THRESHOLD, MAX_PLAUSIBLE_CHANGE, PREMIUM_THRESHOLD } from '@rareindex/valuation'; | |
| 5 | 5 | import { PageHeader } from '@/components/ui/page-header'; |
| 6 | 6 | import { Badge, Card } from '@/components/ui/primitives'; |
| 7 | 7 | import { cn } from '@/lib/format'; |
@@ -199,6 +199,7 @@ export default function MethodologyPage() { | ||
| 199 | 199 | ['Deal', `discount ≤ ${pct(DEAL_THRESHOLD)} (ask at least ${pct(-DEAL_THRESHOLD)} below RIV)`], |
| 200 | 200 | ['Fair', `between ${pct(DEAL_THRESHOLD)} and ${pct(PREMIUM_THRESHOLD)}`], |
| 201 | 201 | ['Premium', `discount ≥ ${pct(PREMIUM_THRESHOLD)}`], |
| 202 | + ['Needs review', `more than ${Math.round(-DEAL_REVIEW_THRESHOLD * 100)} % below RIV — kept as a number, flagged riv_review, excluded from every deal rail, Deal Radar, digest and Rare Radar until reviewed (§174)`], | |
| 202 | 203 | ['Data/identity anomaly', `ratio outside ${ASK_ANOMALY_LOW_RATIO}×–${ASK_ANOMALY_HIGH_RATIO}× — wrong variant, lot, currency, typo or mismatch until reviewed`], |
| 203 | 204 | ['Not compared', 'one of the gates failed; the reason is shown (e.g. riv_sample, riv_basis_guide)'], |
| 204 | 205 | ]} |
modified
apps/web/src/components/ui/primitives.tsx
+7 −0
@@ -53,6 +53,13 @@ export function VsRiv({ discount, className, digits = 1, showLabel = true }: { d | ||
| 53 | 53 | </span> |
| 54 | 54 | ); |
| 55 | 55 | } |
| 56 | + if (discount < -0.5) { | |
| 57 | + return ( | |
| 58 | + <span className={cn('inline-flex items-center gap-1 rounded-sm bg-alert-bg px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-alert', className)} title={`Asking price ${fmtPct(Math.abs(discount), digits, false)} below RIV — more than 50 % below a valuation is almost always a different item, grade or lot. Held for review, not a deal.`}> | |
| 59 | + Needs review · {fmtPct(discount, 0)} | |
| 60 | + </span> | |
| 61 | + ); | |
| 62 | + } | |
| 56 | 63 | const tone = discount <= -0.1 ? 'text-gain' : discount >= 0.1 ? 'text-loss' : 'text-flat'; |
| 57 | 64 | const word = discount <= -0.1 ? 'below RIV' : discount >= 0.1 ? 'above RIV' : 'near RIV'; |
| 58 | 65 | return ( |
modified
apps/web/src/lib/account/queries.ts
+1 −1
@@ -236,7 +236,7 @@ export async function dealRadar(userId: string, opts: { minDiscount?: number; li | ||
| 236 | 236 | join assets a on a.id = l.asset_id |
| 237 | 237 | join asset_stats s on s.asset_id = a.id |
| 238 | 238 | where l.availability = 'available' |
| 239 | − and l.discount_to_riv is not null and l.discount_to_riv <= ${-minDiscount} | |
| 239 | + and l.discount_to_riv is not null and l.discount_to_riv <= ${-minDiscount} and l.discount_to_riv >= -0.5 and not ('riv_review' = any(l.flags)) | |
| 240 | 240 | and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5 |
| 241 | 241 | and (a.category_slug in (select category_slug from universe) or a.id in (select asset_id from watched)) |
| 242 | 242 | order by watched desc, l.discount_to_riv asc |
modified
apps/web/src/lib/ai/scanner.ts
+2 −1
@@ -113,7 +113,8 @@ export async function runScan(input: ScanInput): Promise<ScanResult> { | ||
| 113 | 113 | const gradedMismatch = Boolean(listing.grader && listing.grader !== 'raw' && best.context.variants.length && !best.context.variants.some((v) => (v.grader as string | null) === listing.grader && (v.grade as string | null) === listing.grade)); |
| 114 | 114 | const a = assessAsk({ askUsd: fx?.usd ?? null, rivUsd: best.rivUsd, confidence: best.rivConfidence, sampleSize: best.rivSampleSize, sameVariant: gradedMismatch ? false : undefined }); |
| 115 | 115 | listing.discountToRiv = a.discount; |
| 116 | − listing.verdict = a.verdict === 'deal' ? 'below_fair_value' : a.verdict === 'premium' ? 'above_fair_value' : a.verdict === 'fair' ? 'in_range' : a.verdict === 'anomaly' ? 'anomaly' : 'unknown'; | |
| 116 | + listing.verdict = a.verdict === 'deal' ? 'below_fair_value' : a.verdict === 'premium' ? 'above_fair_value' : a.verdict === 'fair' ? 'in_range' : a.verdict === 'anomaly' || a.verdict === 'review' ? 'anomaly' : 'unknown'; | |
| 117 | + if (a.verdict === 'review') notes.push('The ask is more than 50 % below the matched valuation: almost always a different item, grade or lot. Held for review, not a deal.'); | |
| 117 | 118 | if (a.verdict === 'anomaly') notes.push('The asking price is implausible against the matched valuation (below 10 % or above 10× RIV): most likely a different variant, a lot, a currency or an identity mismatch — review before treating it as a deal.'); |
| 118 | 119 | else if (a.verdict === 'ungated') notes.push(`The ask was not compared with the valuation (${a.reasons.join(', ')}).`); |
| 119 | 120 | } |
modified
apps/web/src/lib/queries/assets.ts
+3 −3
@@ -204,9 +204,9 @@ export async function rankedAssets(kind: RankedKind, opts: { scope?: string[]; l | ||
| 204 | 204 | watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` }, |
| 205 | 205 | newest: { where: sql`true`, order: sql`a.created_at DESC` }, |
| 206 | 206 | expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` }, |
| 207 | − // value_opportunity = (best gated ask − RIV) / RIV: negative = below the valuation. A deal is ≤ −10 %; the | |
| 208 | − // worker never stores implausible ratios, the ≥ −0.9 bound is a second safety net (§84, §196). | |
| 209 | − opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity <= -0.1 AND s.value_opportunity >= -0.9 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5`, order: sql`s.value_opportunity ASC` }, | |
| 207 | + // value_opportunity = (best gated ask − RIV) / RIV: negative = below the valuation. A deal is between −10 % and | |
| 208 | + // −50 %; deeper discounts are review cases (§174), implausible ratios never reach the table (§84, §196). | |
| 209 | + opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity <= -0.1 AND s.value_opportunity >= -0.5 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5`, order: sql`s.value_opportunity ASC` }, | |
| 210 | 210 | liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` }, |
| 211 | 211 | // Fallback rails used while valuations are still being computed (§192: honest, data-backed) |
| 212 | 212 | newest_priced: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.updated_at DESC` }, |
modified
apps/web/src/lib/queries/market-lists.ts
+2 −1
@@ -97,7 +97,8 @@ export async function listListings(f: ListingsFilters): Promise<{ items: Listing | ||
| 97 | 97 | if (f.grader) where.push(sql`l.grader = ${f.grader}`); |
| 98 | 98 | if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`); |
| 99 | 99 | if (f.maxUsd != null) where.push(sql`l.price_usd <= ${f.maxUsd}`); |
| 100 | − if (f.minDiscount != null) where.push(sql`l.discount_to_riv <= ${-Math.abs(f.minDiscount)}`); // stored negative = below RIV | |
| 100 | + if (f.minDiscount != null) where.push(sql`l.discount_to_riv <= ${-Math.abs(f.minDiscount)} AND NOT ('riv_review' = ANY(l.flags))`); // stored negative = below RIV; review cases excluded | |
| 101 | + if (f.sort === 'discount') where.push(sql`NOT ('riv_review' = ANY(l.flags))`); | |
| 101 | 102 | if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`); |
| 102 | 103 | if (f.q && f.q.trim().length >= 2) where.push(sql`(a.title ILIKE ${'%' + f.q.trim() + '%'} OR l.raw_title ILIKE ${'%' + f.q.trim() + '%'})`); |
| 103 | 104 | const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = { |
modified
apps/web/src/lib/queries/screener.ts
+1 −1
@@ -108,7 +108,7 @@ function whereFor(f: ScreenerFilters): SQL { | ||
| 108 | 108 | w.push(sql`(title ILIKE ${'%' + q + '%'} OR title % ${q} OR set_name ILIKE ${'%' + q + '%'})`); |
| 109 | 109 | } |
| 110 | 110 | // the "Potentially Underpriced" sort/preset must never surface an ungated or implausible discount |
| 111 | − if (f.sort === 'opportunity') w.push(sql`value_opportunity IS NOT NULL AND value_opportunity >= -0.9 AND value_opportunity <= -0.1 AND riv_sample_size >= 5 AND riv_confidence >= 0.5`); | |
| 111 | + if (f.sort === 'opportunity') w.push(sql`value_opportunity IS NOT NULL AND value_opportunity >= -0.5 AND value_opportunity <= -0.1 AND riv_sample_size >= 5 AND riv_confidence >= 0.5`); | |
| 112 | 112 | return joinAnd(w); |
| 113 | 113 | } |
| 114 | 114 | |
modified
apps/web/src/lib/screener.ts
+1 −1
@@ -149,7 +149,7 @@ export const PRESETS: ScreenerPreset[] = [ | ||
| 149 | 149 | { id: 'drawdown', label: 'Largest Drawdowns', description: 'RIV furthest below the all-time-high verified sale (≥ 5 sales, medium+ confidence).', filters: { sort: 'drawdown', dir: 'asc', drawdownMax: -0.3, confidenceMin: 0.5 } }, |
| 150 | 150 | { id: 'ath', label: 'Near ATH', description: 'RIV within 5 % of the all-time-high verified sale.', filters: { sort: 'riv', dir: 'desc', drawdownMin: -0.05, confidenceMin: 0.5 } }, |
| 151 | 151 | { id: 'volume', label: 'Increasing Volume', description: 'Sales in the last 30 days at least 2× the trailing 12-month monthly average.', filters: { sort: 'sales30d', dir: 'desc', volumeAccelMin: 2, sales1yMin: 6 } }, |
| 152 | − { id: 'underpriced', label: 'Potentially Underpriced', description: 'Best gated ask 10–90 % below a transaction-based RIV (≥ 5 sales, medium+ confidence). Analytical data, not advice.', filters: { sort: 'opportunity', dir: 'asc', spreadMax: -0.1, confidenceMin: 0.5 } }, | |
| 152 | + { id: 'underpriced', label: 'Potentially Underpriced', description: 'Best gated ask 10–50 % below a transaction-based RIV (≥ 5 sales, medium+ confidence). Analytical data, not advice.', filters: { sort: 'opportunity', dir: 'asc', spreadMax: -0.1, confidenceMin: 0.5 } }, | |
| 153 | 153 | ]; |
| 154 | 154 | |
| 155 | 155 | export function isPlausibleChange(v: number | null | undefined): v is number { |
modified
packages/valuation/src/scores.ts
+10 −3
@@ -55,8 +55,10 @@ export const ASK_MIN_MATCH_CONFIDENCE = 0.7; | ||
| 55 | 55 | /** Threshold below/above which an ask is called a deal / a premium. */ |
| 56 | 56 | export const DEAL_THRESHOLD = -0.1; |
| 57 | 57 | export const PREMIUM_THRESHOLD = 0.1; |
| 58 | +/** An ask more than 50 % below RIV is almost always a mismatch (§174): kept as a number, surfaced as "needs review", never as a deal. */ | |
| 59 | +export const DEAL_REVIEW_THRESHOLD = -0.5; | |
| 58 | 60 | |
| 59 | −export type AskVerdict = 'deal' | 'fair' | 'premium' | 'anomaly' | 'ungated'; | |
| 61 | +export type AskVerdict = 'deal' | 'fair' | 'premium' | 'review' | 'anomaly' | 'ungated'; | |
| 60 | 62 | |
| 61 | 63 | export interface AskAssessment { |
| 62 | 64 | /** (ask − RIV) / RIV, 4 decimals; null when ungated or anomalous */ |
@@ -102,6 +104,7 @@ export function assessAsk(i: AskAssessmentInput): AskAssessment { | ||
| 102 | 104 | return { discount: null, ratio: round(ratio, 4), verdict: 'anomaly', reasons: [ratio < 1 ? 'ask_implausibly_low' : 'ask_implausibly_high'] }; |
| 103 | 105 | } |
| 104 | 106 | const discount = round(ratio - 1, 4); |
| 107 | + if (discount < DEAL_REVIEW_THRESHOLD) return { discount, ratio: round(ratio, 4), verdict: 'review', reasons: ['discount_exceeds_review_threshold'] }; | |
| 105 | 108 | return { discount, ratio: round(ratio, 4), verdict: discount <= DEAL_THRESHOLD ? 'deal' : discount >= PREMIUM_THRESHOLD ? 'premium' : 'fair', reasons }; |
| 106 | 109 | } |
| 107 | 110 | |
@@ -111,7 +114,7 @@ export function assessAsk(i: AskAssessmentInput): AskAssessment { | ||
| 111 | 114 | */ |
| 112 | 115 | export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null, sampleSize?: number | null): number | null { |
| 113 | 116 | const a = assessAsk({ askUsd, rivUsd: riv, confidence, sampleSize: sampleSize ?? undefined }); |
| 114 | − return a.verdict === 'anomaly' || a.verdict === 'ungated' ? null : a.discount; | |
| 117 | + return a.verdict === 'anomaly' || a.verdict === 'ungated' || a.verdict === 'review' ? null : a.discount; | |
| 115 | 118 | } |
| 116 | 119 | |
| 117 | 120 | /** |
@@ -120,7 +123,7 @@ export function valueOpportunity(askUsd: number | null, riv: number | null, conf | ||
| 120 | 123 | * multiplicative so one weak leg (e.g. an illiquid asset) cannot be compensated by a huge discount. |
| 121 | 124 | */ |
| 122 | 125 | export function dealScore(input: { discount: number | null; confidence: number | null; sampleSize: number | null; liquidity: number | null; matchConfidence?: number | null }): number { |
| 123 | − if (input.discount === null || input.discount >= 0) return 0; | |
| 126 | + if (input.discount === null || input.discount >= 0 || input.discount < DEAL_REVIEW_THRESHOLD) return 0; | |
| 124 | 127 | const depth = clamp(-input.discount / 0.5, 0, 1) ** 0.7; // 50 % below → 1, concave so 10 % is already meaningful |
| 125 | 128 | const conf = 0.4 + 0.6 * clamp(input.confidence ?? 0, 0, 1); |
| 126 | 129 | const sample = 0.5 + 0.5 * clamp(Math.log10(1 + (input.sampleSize ?? 0)) / Math.log10(41), 0, 1); // 40 sales → 1 |
@@ -137,6 +140,10 @@ export const MAX_PLAUSIBLE_CHANGE = 5; | ||
| 137 | 140 | export function isPlausibleChange(value: number | null | undefined): boolean { |
| 138 | 141 | return value !== null && value !== undefined && Number.isFinite(value) && Math.abs(value) <= MAX_PLAUSIBLE_CHANGE; |
| 139 | 142 | } |
| 143 | +/** A discount that may be shown as a deal (not an anomaly, not beyond the review threshold). */ | |
| 144 | +export function isActionableDiscount(value: number | null | undefined): boolean { | |
| 145 | + return value !== null && value !== undefined && Number.isFinite(value) && value >= DEAL_REVIEW_THRESHOLD && isPlausibleDiscount(value); | |
| 146 | +} | |
| 140 | 147 | export function isPlausibleDiscount(value: number | null | undefined): boolean { |
| 141 | 148 | if (value === null || value === undefined || !Number.isFinite(value)) return false; |
| 142 | 149 | const ratio = 1 + value; |
modified
packages/valuation/src/valuation.test.ts
+8 −0
@@ -118,6 +118,14 @@ describe('ask vs RIV gate (§83–§84, §196)', () => { | ||
| 118 | 118 | expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30, feesUsd: 150 }).verdict).toBe('fair'); |
| 119 | 119 | expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30 }).verdict).toBe('deal'); |
| 120 | 120 | }); |
| 121 | + it('sends asks more than 50 % below RIV to review instead of calling them deals (§174)', () => { | |
| 122 | + const a = assessAsk({ askUsd: 115.8, rivUsd: 1077.54, confidence: 0.8, sampleSize: 58 }); // a booster pack ask matched to a booster-box RIV | |
| 123 | + expect(a.verdict).toBe('review'); | |
| 124 | + expect(a.discount).toBeCloseTo(-0.8925, 3); | |
| 125 | + expect(valueOpportunity(115.8, 1077.54, 0.8, 58)).toBeNull(); | |
| 126 | + expect(dealScore({ discount: -0.89, confidence: 0.8, sampleSize: 58, liquidity: 60 })).toBe(0); | |
| 127 | + expect(assessAsk({ askUsd: 520, rivUsd: 1000, confidence: 0.8, sampleSize: 58 }).verdict).toBe('deal'); | |
| 128 | + }); | |
| 121 | 129 | it('deal score is 0 without a gated discount and rewards confidence, depth and liquidity', () => { |
| 122 | 130 | expect(dealScore({ discount: null, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); |
| 123 | 131 | expect(dealScore({ discount: 0.2, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); |
modified
workers/account/digest.ts
+1 −1
@@ -44,7 +44,7 @@ export async function runDigests(db: Database, now = new Date()): Promise<number | ||
| 44 | 44 | `)) as unknown as Array<{ title: string; slug: string; riv_usd: number | null; change_7d: number | null; change_1d: number | null }>; |
| 45 | 45 | const deals = (await db.execute(sql` |
| 46 | 46 | select a.title, a.slug, l.price_usd, l.discount_to_riv from listings l join assets a on a.id = l.asset_id join asset_stats s on s.asset_id = a.id |
| 47 | − where l.availability = 'available' and l.discount_to_riv <= -0.15 and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5 | |
| 47 | + where l.availability = 'available' and l.discount_to_riv <= -0.15 and l.discount_to_riv >= -0.5 and not ('riv_review' = any(l.flags)) and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5 | |
| 48 | 48 | and a.category_slug in ( |
| 49 | 49 | select distinct a2.category_slug from collection_items ci join collections c on c.id = ci.collection_id join assets a2 on a2.id = ci.asset_id where c.user_id = ${m.id} |
| 50 | 50 | union select wi.target_id from watchlist_items wi join watchlists w on w.id = wi.watchlist_id where w.user_id = ${m.id} and wi.target_type = 'category' |
modified
workers/indices/run.ts
+1 −1
@@ -262,7 +262,7 @@ export async function runRadar(opts: { now?: Date } = {}): Promise<number> { | ||
| 262 | 262 | const cheap = await db().execute(sql` |
| 263 | 263 | select l.id, l.asset_id, l.discount_to_riv::float as d, l.price_usd::float as ask, s.riv_usd::float as riv, s.riv_confidence::float as conf |
| 264 | 264 | from listings l join asset_stats s on s.asset_id = l.asset_id |
| 265 | − where l.availability = 'available' and l.discount_to_riv <= -0.25 and s.riv_confidence >= 0.6 and l.price_usd > 0 limit 2000`); | |
| 265 | + where l.availability = 'available' and l.discount_to_riv <= -0.25 and l.discount_to_riv >= -0.5 and not ('riv_review' = any(l.flags)) and s.riv_confidence >= 0.6 and s.riv_sample_size >= 5 and l.price_usd > 0 limit 2000`); | |
| 266 | 266 | for (const r of cheap as unknown as Array<{ id: string; asset_id: string; d: number; ask: number; riv: number; conf: number }>) { |
| 267 | 267 | await db().insert(radarFindings).values({ id: newId('event'), assetId: r.asset_id, kind: 'price_discrepancy', score: -r.d * r.conf, evidence: { askUsd: r.ask, rivUsd: r.riv, discount: r.d, confidence: r.conf }, entityType: 'listing', entityId: r.id, detectedAt: now, expiresAt: new Date(now.getTime() + 7 * DAY) }).onConflictDoUpdate({ target: [radarFindings.kind, radarFindings.entityType, radarFindings.entityId], set: { detectedAt: now, score: -r.d * r.conf, evidence: { askUsd: r.ask, rivUsd: r.riv, discount: r.d, confidence: r.conf } } }); |
| 268 | 268 | n++; |
modified
workers/valuation/run.ts
+7 −3
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { and, desc, eq, gte, inArray, isNull, or, sql } from 'drizzle-orm'; |
| 2 | 2 | import { assetStats, assetVariants, assets, gradePremiums, listings, populationReports, priceObservations, priceSnapshots, sales, sources, valuations, variantStats } from '@rareindex/database'; |
| 3 | −import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, adjustmentFactor, assetDataQuality, computeGradePremiums, computeValuation, detectOutliers, liquidityScore, momentumScore, rarityScore, trendingScore, type GradePremium, type SaleInput } from '@rareindex/valuation'; | |
| 3 | +import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_REVIEW_THRESHOLD, adjustmentFactor, assetDataQuality, computeGradePremiums, computeValuation, detectOutliers, liquidityScore, momentumScore, rarityScore, trendingScore, type GradePremium, type SaleInput } from '@rareindex/valuation'; | |
| 4 | 4 | import { logger, median, newId, pctChange, toDateOnly } from '@rareindex/shared'; |
| 5 | 5 | import { db } from '../lib/db.ts'; |
| 6 | 6 | import { auditMany } from '../lib/audit.ts'; |
@@ -156,7 +156,11 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis | ||
| 156 | 156 | const scope = and(eq(listings.variantId, v.id), eq(listings.availability, 'available'), sql`${listings.priceUsd} > 0`, sql`${listings.listingType} <> 'auction'`); |
| 157 | 157 | await db() |
| 158 | 158 | .update(listings) |
| 159 | − .set({ discountToRiv: sql`round((${listings.priceUsd} - ${out.riv}) / ${out.riv}, 4)`, flags: sql`array_remove(${listings.flags}, 'riv_anomaly')` }) | |
| 159 | + .set({ | |
| 160 | + discountToRiv: sql`round((${listings.priceUsd} - ${out.riv}) / ${out.riv}, 4)`, | |
| 161 | + // > 50 % below RIV (§174): keep the number, flag `riv_review`, never surface as a deal | |
| 162 | + flags: sql`case when ${listings.priceUsd} < ${out.riv * (1 + DEAL_REVIEW_THRESHOLD)} then (case when 'riv_review' = any(${listings.flags}) then array_remove(${listings.flags}, 'riv_anomaly') else array_append(array_remove(${listings.flags}, 'riv_anomaly'), 'riv_review') end) else array_remove(array_remove(${listings.flags}, 'riv_anomaly'), 'riv_review') end`, | |
| 163 | + }) | |
| 160 | 164 | .where(and(scope, sql`${listings.priceUsd} between ${lo} and ${hi}`, sql`${listings.confidence} >= ${ASK_MIN_MATCH_CONFIDENCE}`, sql`not (${listings.grader} is not null and ${listings.grade} is null)`)); |
| 161 | 165 | await db() |
| 162 | 166 | .update(listings) |
@@ -205,7 +209,7 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis | ||
| 205 | 209 | const trending = trendingScore({ priceMomentum: mom.m30, volumeMomentum: momentumScore({ priceChange: null, volumeNow: sales30d, volumePrev: salesPrev30 }), searchMomentum: null, listingMomentum, newsMomentum: null }); |
| 206 | 210 | // Best gated ask across the asset's variants (negative = below its own variant's RIV). No fallback: |
| 207 | 211 | // comparing the cheapest ask of ANY variant against the headline RIV was the −8,500 % bug. |
| 208 | − const [bestListing] = await db().select({ d: sql<number | null>`min(${listings.discountToRiv})` }).from(listings).where(and(eq(listings.assetId, assetId), eq(listings.availability, 'available'), sql`${listings.discountToRiv} is not null`)); | |
| 212 | + const [bestListing] = await db().select({ d: sql<number | null>`min(${listings.discountToRiv})` }).from(listings).where(and(eq(listings.assetId, assetId), eq(listings.availability, 'available'), sql`${listings.discountToRiv} is not null`, sql`not ('riv_review' = any(${listings.flags}))`)); | |
| 209 | 213 | const fields = { brand: asset.brand, set: asset.setName, number: asset.number, year: asset.year, variant: asset.variant, image: asset.heroImageUrl, description: asset.description, identifiers: Object.keys(asset.identifiers).length ? 1 : null }; |
| 210 | 214 | const dq = assetDataQuality({ fieldsPresent: Object.values(fields).filter((x) => x !== null && x !== undefined).length, fieldsTotal: Object.keys(fields).length, sourceTrustAvg: sourcesCount ? median([...new Set(saleRows.map((s) => s.sourceId))].map((s) => trust.get(s) ?? 0.5)) : null, identificationConfidence: rep ? Number(median(validSales.map((s) => Number(s.confidence))) ?? 0.8) : null, hasImage: Boolean(asset.heroImageUrl), salesCount }); |
| 211 | 215 | const statsRow = { |
| 212 | 216 | |