SPB Git

spb/rareindex

Public

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>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 17ff07f Browse files

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
Menu

Theme