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 | import type { Metadata } from 'next'; | 1 | import type { Metadata } from 'next'; | ||
| 2 | import Link from 'next/link'; | 2 | import Link from 'next/link'; | ||
| 3 | import type { ReactNode } from 'react'; | 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 | import { PageHeader } from '@/components/ui/page-header'; | 5 | import { PageHeader } from '@/components/ui/page-header'; | ||
| 6 | import { Badge, Card } from '@/components/ui/primitives'; | 6 | import { Badge, Card } from '@/components/ui/primitives'; | ||
| 7 | import { cn } from '@/lib/format'; | 7 | import { cn } from '@/lib/format'; | ||
@@ -199,6 +199,7 @@ export default function MethodologyPage() { | |||||
| 199 | ['Deal', `discount ≤ ${pct(DEAL_THRESHOLD)} (ask at least ${pct(-DEAL_THRESHOLD)} below RIV)`], | 199 | ['Deal', `discount ≤ ${pct(DEAL_THRESHOLD)} (ask at least ${pct(-DEAL_THRESHOLD)} below RIV)`], | ||
| 200 | ['Fair', `between ${pct(DEAL_THRESHOLD)} and ${pct(PREMIUM_THRESHOLD)}`], | 200 | ['Fair', `between ${pct(DEAL_THRESHOLD)} and ${pct(PREMIUM_THRESHOLD)}`], | ||
| 201 | ['Premium', `discount ≥ ${pct(PREMIUM_THRESHOLD)}`], | 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 | ['Data/identity anomaly', `ratio outside ${ASK_ANOMALY_LOW_RATIO}×–${ASK_ANOMALY_HIGH_RATIO}× — wrong variant, lot, currency, typo or mismatch until reviewed`], | 203 | ['Data/identity anomaly', `ratio outside ${ASK_ANOMALY_LOW_RATIO}×–${ASK_ANOMALY_HIGH_RATIO}× — wrong variant, lot, currency, typo or mismatch until reviewed`], | ||
| 203 | ['Not compared', 'one of the gates failed; the reason is shown (e.g. riv_sample, riv_basis_guide)'], | 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 | </span> | 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 | const tone = discount <= -0.1 ? 'text-gain' : discount >= 0.1 ? 'text-loss' : 'text-flat'; | 63 | const tone = discount <= -0.1 ? 'text-gain' : discount >= 0.1 ? 'text-loss' : 'text-flat'; | ||
| 57 | const word = discount <= -0.1 ? 'below RIV' : discount >= 0.1 ? 'above RIV' : 'near RIV'; | 64 | const word = discount <= -0.1 ? 'below RIV' : discount >= 0.1 ? 'above RIV' : 'near RIV'; | ||
| 58 | return ( | 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 | join assets a on a.id = l.asset_id | 236 | join assets a on a.id = l.asset_id | ||
| 237 | join asset_stats s on s.asset_id = a.id | 237 | join asset_stats s on s.asset_id = a.id | ||
| 238 | where l.availability = 'available' | 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 | and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5 | 240 | and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5 | ||
| 241 | and (a.category_slug in (select category_slug from universe) or a.id in (select asset_id from watched)) | 241 | and (a.category_slug in (select category_slug from universe) or a.id in (select asset_id from watched)) | ||
| 242 | order by watched desc, l.discount_to_riv asc | 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 | 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)); | 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 | const a = assessAsk({ askUsd: fx?.usd ?? null, rivUsd: best.rivUsd, confidence: best.rivConfidence, sampleSize: best.rivSampleSize, sameVariant: gradedMismatch ? false : undefined }); | 114 | const a = assessAsk({ askUsd: fx?.usd ?? null, rivUsd: best.rivUsd, confidence: best.rivConfidence, sampleSize: best.rivSampleSize, sameVariant: gradedMismatch ? false : undefined }); | ||
| 115 | listing.discountToRiv = a.discount; | 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 | 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 | 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 | else if (a.verdict === 'ungated') notes.push(`The ask was not compared with the valuation (${a.reasons.join(', ')}).`); | 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 | watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` }, | 204 | watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` }, | ||
| 205 | newest: { where: sql`true`, order: sql`a.created_at DESC` }, | 205 | newest: { where: sql`true`, order: sql`a.created_at DESC` }, | ||
| 206 | expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` }, | 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 | 207 | + // value_opportunity = (best gated ask − RIV) / RIV: negative = below the valuation. A deal is between −10 % and | ||
| 208 | − // worker never stores implausible ratios, the ≥ −0.9 bound is a second safety net (§84, §196). | 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.9 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5`, order: sql`s.value_opportunity ASC` }, | 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 | liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` }, | 210 | liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` }, | ||
| 211 | // Fallback rails used while valuations are still being computed (§192: honest, data-backed) | 211 | // Fallback rails used while valuations are still being computed (§192: honest, data-backed) | ||
| 212 | newest_priced: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.updated_at DESC` }, | 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 | if (f.grader) where.push(sql`l.grader = ${f.grader}`); | 97 | if (f.grader) where.push(sql`l.grader = ${f.grader}`); | ||
| 98 | if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`); | 98 | if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`); | ||
| 99 | if (f.maxUsd != null) where.push(sql`l.price_usd <= ${f.maxUsd}`); | 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 | if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`); | 102 | if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`); | ||
| 102 | 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 | 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 | const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = { | 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 | w.push(sql`(title ILIKE ${'%' + q + '%'} OR title % ${q} OR set_name ILIKE ${'%' + q + '%'})`); | 108 | w.push(sql`(title ILIKE ${'%' + q + '%'} OR title % ${q} OR set_name ILIKE ${'%' + q + '%'})`); | ||
| 109 | } | 109 | } | ||
| 110 | // the "Potentially Underpriced" sort/preset must never surface an ungated or implausible discount | 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 | return joinAnd(w); | 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 | { 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 } }, | 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 | { 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 } }, | 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 | { 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 } }, | 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 | export function isPlausibleChange(v: number | null | undefined): v is number { | 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 | /** Threshold below/above which an ask is called a deal / a premium. */ | 55 | /** Threshold below/above which an ask is called a deal / a premium. */ | ||
| 56 | export const DEAL_THRESHOLD = -0.1; | 56 | export const DEAL_THRESHOLD = -0.1; | ||
| 57 | export const PREMIUM_THRESHOLD = 0.1; | 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 | export interface AskAssessment { | 63 | export interface AskAssessment { | ||
| 62 | /** (ask − RIV) / RIV, 4 decimals; null when ungated or anomalous */ | 64 | /** (ask − RIV) / RIV, 4 decimals; null when ungated or anomalous */ | ||
@@ -102,6 +104,7 @@ export function assessAsk(i: AskAssessmentInput): AskAssessment { | |||||
| 102 | return { discount: null, ratio: round(ratio, 4), verdict: 'anomaly', reasons: [ratio < 1 ? 'ask_implausibly_low' : 'ask_implausibly_high'] }; | 104 | return { discount: null, ratio: round(ratio, 4), verdict: 'anomaly', reasons: [ratio < 1 ? 'ask_implausibly_low' : 'ask_implausibly_high'] }; | ||
| 103 | } | 105 | } | ||
| 104 | const discount = round(ratio - 1, 4); | 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 | return { discount, ratio: round(ratio, 4), verdict: discount <= DEAL_THRESHOLD ? 'deal' : discount >= PREMIUM_THRESHOLD ? 'premium' : 'fair', reasons }; | 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 | export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null, sampleSize?: number | null): number | null { | 115 | export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null, sampleSize?: number | null): number | null { | ||
| 113 | const a = assessAsk({ askUsd, rivUsd: riv, confidence, sampleSize: sampleSize ?? undefined }); | 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 | * multiplicative so one weak leg (e.g. an illiquid asset) cannot be compensated by a huge discount. | 123 | * multiplicative so one weak leg (e.g. an illiquid asset) cannot be compensated by a huge discount. | ||
| 121 | */ | 124 | */ | ||
| 122 | export function dealScore(input: { discount: number | null; confidence: number | null; sampleSize: number | null; liquidity: number | null; matchConfidence?: number | null }): number { | 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 | const depth = clamp(-input.discount / 0.5, 0, 1) ** 0.7; // 50 % below → 1, concave so 10 % is already meaningful | 127 | const depth = clamp(-input.discount / 0.5, 0, 1) ** 0.7; // 50 % below → 1, concave so 10 % is already meaningful | ||
| 125 | const conf = 0.4 + 0.6 * clamp(input.confidence ?? 0, 0, 1); | 128 | const conf = 0.4 + 0.6 * clamp(input.confidence ?? 0, 0, 1); | ||
| 126 | const sample = 0.5 + 0.5 * clamp(Math.log10(1 + (input.sampleSize ?? 0)) / Math.log10(41), 0, 1); // 40 sales → 1 | 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 | export function isPlausibleChange(value: number | null | undefined): boolean { | 140 | export function isPlausibleChange(value: number | null | undefined): boolean { | ||
| 138 | return value !== null && value !== undefined && Number.isFinite(value) && Math.abs(value) <= MAX_PLAUSIBLE_CHANGE; | 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 | export function isPlausibleDiscount(value: number | null | undefined): boolean { | 147 | export function isPlausibleDiscount(value: number | null | undefined): boolean { | ||
| 141 | if (value === null || value === undefined || !Number.isFinite(value)) return false; | 148 | if (value === null || value === undefined || !Number.isFinite(value)) return false; | ||
| 142 | const ratio = 1 + value; | 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 | expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30, feesUsd: 150 }).verdict).toBe('fair'); | 118 | expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30, feesUsd: 150 }).verdict).toBe('fair'); | ||
| 119 | expect(assessAsk({ askUsd: 900, rivUsd: 1000, confidence: 0.9, sampleSize: 30 }).verdict).toBe('deal'); | 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 | it('deal score is 0 without a gated discount and rewards confidence, depth and liquidity', () => { | 129 | it('deal score is 0 without a gated discount and rewards confidence, depth and liquidity', () => { | ||
| 122 | expect(dealScore({ discount: null, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); | 130 | expect(dealScore({ discount: null, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); | ||
| 123 | expect(dealScore({ discount: 0.2, confidence: 0.9, sampleSize: 30, liquidity: 70 })).toBe(0); | 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 | `)) as unknown as Array<{ title: string; slug: string; riv_usd: number | null; change_7d: number | null; change_1d: number | null }>; | 44 | `)) as unknown as Array<{ title: string; slug: string; riv_usd: number | null; change_7d: number | null; change_1d: number | null }>; | ||
| 45 | const deals = (await db.execute(sql` | 45 | const deals = (await db.execute(sql` | ||
| 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 | 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 | and a.category_slug in ( | 48 | and a.category_slug in ( | ||
| 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} | 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 | 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' | 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 | const cheap = await db().execute(sql` | 262 | const cheap = await db().execute(sql` | ||
| 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 | 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 | from listings l join asset_stats s on s.asset_id = l.asset_id | 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 | for (const r of cheap as unknown as Array<{ id: string; asset_id: string; d: number; ask: number; riv: number; conf: number }>) { | 266 | for (const r of cheap as unknown as Array<{ id: string; asset_id: string; d: number; ask: number; riv: number; conf: number }>) { | ||
| 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 } } }); | 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 | n++; | 268 | n++; | ||
modified
workers/valuation/run.ts
+7 −3
@@ -1,6 +1,6 @@ | |||||
| 1 | import { and, desc, eq, gte, inArray, isNull, or, sql } from 'drizzle-orm'; | 1 | import { and, desc, eq, gte, inArray, isNull, or, sql } from 'drizzle-orm'; | ||
| 2 | import { assetStats, assetVariants, assets, gradePremiums, listings, populationReports, priceObservations, priceSnapshots, sales, sources, valuations, variantStats } from '@rareindex/database'; | 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 | import { logger, median, newId, pctChange, toDateOnly } from '@rareindex/shared'; | 4 | import { logger, median, newId, pctChange, toDateOnly } from '@rareindex/shared'; | ||
| 5 | import { db } from '../lib/db.ts'; | 5 | import { db } from '../lib/db.ts'; | ||
| 6 | import { auditMany } from '../lib/audit.ts'; | 6 | import { auditMany } from '../lib/audit.ts'; | ||
@@ -156,7 +156,11 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis | |||||
| 156 | const scope = and(eq(listings.variantId, v.id), eq(listings.availability, 'available'), sql`${listings.priceUsd} > 0`, sql`${listings.listingType} <> 'auction'`); | 156 | const scope = and(eq(listings.variantId, v.id), eq(listings.availability, 'available'), sql`${listings.priceUsd} > 0`, sql`${listings.listingType} <> 'auction'`); | ||
| 157 | await db() | 157 | await db() | ||
| 158 | .update(listings) | 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 | .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)`)); | 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 | await db() | 165 | await db() | ||
| 162 | .update(listings) | 166 | .update(listings) | ||
@@ -205,7 +209,7 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis | |||||
| 205 | const trending = trendingScore({ priceMomentum: mom.m30, volumeMomentum: momentumScore({ priceChange: null, volumeNow: sales30d, volumePrev: salesPrev30 }), searchMomentum: null, listingMomentum, newsMomentum: null }); | 209 | const trending = trendingScore({ priceMomentum: mom.m30, volumeMomentum: momentumScore({ priceChange: null, volumeNow: sales30d, volumePrev: salesPrev30 }), searchMomentum: null, listingMomentum, newsMomentum: null }); | ||
| 206 | // Best gated ask across the asset's variants (negative = below its own variant's RIV). No fallback: | 210 | // Best gated ask across the asset's variants (negative = below its own variant's RIV). No fallback: | ||
| 207 | // comparing the cheapest ask of ANY variant against the headline RIV was the −8,500 % bug. | 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 | 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 }; | 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 | 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 }); | 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 | const statsRow = { | 215 | const statsRow = { | ||
| 212 | 216 | ||||