Valuation safety (phase 1): ask-vs-RIV anomaly gate, variant-aware discounts, grade parsing fixes
- scores: assessAsk()/dealScore()/plausibility gates — an ask is compared only with a transaction-based RIV of the SAME variant (>=5 sales, confidence >=0.5, match >=0.7); asks <10% or >10x RIV are flagged riv_anomaly, never surfaced as deals - valuation worker: discounts reset each pass, representative variant prefers the default (raw/base) variant when it qualifies, changes measured on the representative variant's own series, no cross-variant fallback for value_opportunity, numeric(8,6) overflow guard - taxonomy: parse PSA/DNA n, (8.5), Authentic-only slabs; a grader without a readable grade gets its own 'grader|unknown' variant instead of polluting raw - resolver/writers: refineGrade() re-parses titles for sales, listings, observations, lots - web: VsRiv primitive (Data/identity anomaly label), opportunity/gainers/losers/listings sort + filter directions fixed (negative = below RIV), scanner verdict via assessAsk with FX - indices: RARE publishes only when its 25-constituent minimum is met - security: atomic ai_quotas upsert with unique key, scanner choose ownership check, anonymous research thread authz - db: migration 0006 (hot-path indexes, ai_quotas unique); ri regrade repair command Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
31 changed files +9,556 −101
modified
apps/web/package.json
+1 −0
@@ -18,6 +18,7 @@ | ||
| 18 | 18 | "@rareindex/search": "workspace:*", |
| 19 | 19 | "@rareindex/shared": "workspace:*", |
| 20 | 20 | "@rareindex/taxonomy": "workspace:*", |
| 21 | + "@rareindex/valuation": "workspace:*", | |
| 21 | 22 | "@tanstack/react-query": "^5.90.0", |
| 22 | 23 | "drizzle-orm": "^0.45.0", |
| 23 | 24 | "lucide-react": "^1.0.0", |
modified
apps/web/src/app/api/scanner/[id]/choose/route.ts
+4 −1
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { NextResponse } from 'next/server'; |
| 2 | 2 | import { z } from 'zod'; |
| 3 | 3 | import { chooseCandidate } from '@/lib/ai/scanner'; |
| 4 | +import { clientIpHash, currentUserId, getAnonId } from '@/lib/ai/request'; | |
| 4 | 5 | |
| 5 | 6 | export const runtime = 'nodejs'; |
| 6 | 7 | |
@@ -8,6 +9,8 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }> | ||
| 8 | 9 | const { id } = await ctx.params; |
| 9 | 10 | const body = z.object({ assetId: z.string().nullable() }).safeParse(await req.json().catch(() => ({}))); |
| 10 | 11 | if (!body.success || !/^scan_[a-z0-9]+$/.test(id)) return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); |
| 11 | − await chooseCandidate(id, body.data.assetId); | |
| 12 | + const [userId, anonId, ipHash] = await Promise.all([currentUserId(), getAnonId(false), clientIpHash()]); | |
| 13 | + const ok = await chooseCandidate(id, body.data.assetId, { userId, anonId, ipHash }); | |
| 14 | + if (!ok) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 12 | 15 | return NextResponse.json({ ok: true }); |
| 13 | 16 | } |
modified
apps/web/src/app/page.tsx
+1 −1
@@ -205,7 +205,7 @@ async function Discovery() { | ||
| 205 | 205 | <RadarRail items={radar} hideWhenEmpty /> |
| 206 | 206 | <LotsRail items={lots} hideWhenEmpty /> |
| 207 | 207 | <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" /> |
| 208 | − {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV — analytical data, not advice" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null} | |
| 208 | + {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Asks ≥ 10 % below a transaction-based RIV of the same variant (≥ 5 sales, medium+ confidence). Analytical data, not advice; implausible asks are held back as data anomalies." href="/listings?sort=discount&disc=10" items={opportunities} metric="opportunity" /> : null} | |
| 209 | 209 | {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null} |
| 210 | 210 | <MarketInsights rows={markets} /> |
| 211 | 211 | </> |
modified
apps/web/src/components/asset/sales-cards.tsx
+2 −5
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { SaleRow, ListingRow } from '@/lib/queries/assets'; |
| 2 | 2 | import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format'; |
| 3 | 3 | import { humanize } from '@/lib/taxonomy'; |
| 4 | −import { Badge, Delta, EmptyState } from '@/components/ui/primitives'; | |
| 4 | +import { Badge, Delta, EmptyState, VsRiv } from '@/components/ui/primitives'; | |
| 5 | 5 | import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits'; |
| 6 | 6 | |
| 7 | 7 | /** Phone-first card lists for sales and listings (tables stay for md+). */ |
@@ -48,10 +48,7 @@ export function ListingsCards({ items, className, emptyDescription = 'Live listi | ||
| 48 | 48 | <div className="flex items-baseline justify-between gap-2"> |
| 49 | 49 | <span className="num text-[15px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span> |
| 50 | 50 | {l.discountToRiv !== null ? ( |
| 51 | − <span className="inline-flex items-center gap-1 text-[11px]"> | |
| 52 | − <span className="text-subtle">vs RIV</span> | |
| 53 | − <Delta value={-l.discountToRiv} /> | |
| 54 | − </span> | |
| 51 | + <VsRiv discount={l.discountToRiv} className="text-[11px]" /> | |
| 55 | 52 | ) : ( |
| 56 | 53 | <span className="text-[11px] text-subtle">{fmtRelative(l.lastSeenAt)}</span> |
| 57 | 54 | )} |
modified
apps/web/src/components/market/asset-list.tsx
+3 −3
@@ -2,7 +2,7 @@ import Link from 'next/link'; | ||
| 2 | 2 | import type { AssetCard as AssetCardData } from '@/lib/queries/assets'; |
| 3 | 3 | import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format'; |
| 4 | 4 | import { catName } from '@/lib/taxonomy'; |
| 5 | −import { Delta, Table, th, td, tdNum, EmptyState } from '@/components/ui/primitives'; | |
| 5 | +import { Delta, Table, th, td, tdNum, EmptyState, VsRiv } from '@/components/ui/primitives'; | |
| 6 | 6 | import { Thumb } from './bits'; |
| 7 | 7 | import { PriceLine, TileImage } from './asset-tile'; |
| 8 | 8 | |
@@ -81,7 +81,7 @@ function cell(a: AssetCardData, c: AssetColumn) { | ||
| 81 | 81 | case 'trending': |
| 82 | 82 | return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1); |
| 83 | 83 | case 'opportunity': |
| 84 | − return a.valueOpportunity === null ? <span className="text-subtle">—</span> : <Delta value={-a.valueOpportunity} />; | |
| 84 | + return <VsRiv discount={a.valueOpportunity} showLabel={false} />; | |
| 85 | 85 | case 'watchers': |
| 86 | 86 | return fmtNum(a.watchers); |
| 87 | 87 | case 'updated': |
@@ -142,7 +142,7 @@ export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCard | ||
| 142 | 142 | metric === 'latestSale' ? ( |
| 143 | 143 | a.latestSaleUsd !== null && a.rivUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : a.number ? <span className="num text-[11px] text-subtle">#{a.number}</span> : null |
| 144 | 144 | ) : metric === 'opportunity' ? ( |
| 145 | − a.valueOpportunity !== null ? <span className="text-[11px] text-muted">vs RIV <Delta value={-a.valueOpportunity} className="text-[11px]" /></span> : null | |
| 145 | + a.valueOpportunity !== null ? <VsRiv discount={a.valueOpportunity} className="text-[11px]" /> : null | |
| 146 | 146 | ) : metric === 'trending' ? ( |
| 147 | 147 | a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null |
| 148 | 148 | ) : value !== null ? ( |
modified
apps/web/src/components/market/asset-tile.tsx
+2 −4
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; | ||
| 3 | 3 | import type { AssetCard as AssetCardData } from '@/lib/queries/assets'; |
| 4 | 4 | import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format'; |
| 5 | 5 | import { catName } from '@/lib/taxonomy'; |
| 6 | −import { Delta } from '@/components/ui/primitives'; | |
| 6 | +import { Delta, VsRiv } from '@/components/ui/primitives'; | |
| 7 | 7 | |
| 8 | 8 | export type TileMetric = 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide' | 'latestSale'; |
| 9 | 9 | |
@@ -61,9 +61,7 @@ function MetricNode({ a, metric }: { a: AssetCardData; metric: TileMetric }) { | ||
| 61 | 61 | return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>; |
| 62 | 62 | case 'opportunity': |
| 63 | 63 | return ( |
| 64 | − <span className="text-[11px] text-muted"> | |
| 65 | − vs RIV <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[12px]" /> | |
| 66 | − </span> | |
| 64 | + <VsRiv discount={a.valueOpportunity} className="text-[12px]" /> | |
| 67 | 65 | ); |
| 68 | 66 | case 'new': |
| 69 | 67 | return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>; |
modified
apps/web/src/components/market/sales-table.tsx
+3 −3
@@ -2,7 +2,7 @@ import type { SaleRow, ListingRow } from '@/lib/queries/assets'; | ||
| 2 | 2 | import type { LotRow } from '@/lib/queries/market-lists'; |
| 3 | 3 | import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format'; |
| 4 | 4 | import { humanize } from '@/lib/taxonomy'; |
| 5 | −import { Table, th, td, tdNum, EmptyState, Badge, Delta } from '@/components/ui/primitives'; | |
| 5 | +import { Table, th, td, tdNum, EmptyState, Badge, Delta, VsRiv } from '@/components/ui/primitives'; | |
| 6 | 6 | import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits'; |
| 7 | 7 | |
| 8 | 8 | /** Card list for sales on small screens (image, title, grade, price, source). */ |
@@ -119,7 +119,7 @@ export function ListingsCards({ items, className }: { items: ListingRow[]; class | ||
| 119 | 119 | </span> |
| 120 | 120 | <span className="num flex shrink-0 flex-col items-end leading-tight"> |
| 121 | 121 | <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span> |
| 122 | − {l.discountToRiv !== null ? <Delta value={-l.discountToRiv} className="text-[11px]" /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>} | |
| 122 | + {l.discountToRiv !== null ? <VsRiv discount={l.discountToRiv} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>} | |
| 123 | 123 | </span> |
| 124 | 124 | </a> |
| 125 | 125 | </li> |
@@ -186,7 +186,7 @@ export function ListingsTable({ items, showAsset = true, className, emptyDescrip | ||
| 186 | 186 | <span className="text-subtle" title="No valuation to compare against">—</span> |
| 187 | 187 | ) : ( |
| 188 | 188 | <span className="inline-flex flex-col items-end leading-tight"> |
| 189 | − <Delta value={-l.discountToRiv} /> | |
| 189 | + <VsRiv discount={l.discountToRiv} showLabel={false} /> | |
| 190 | 190 | {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null} |
| 191 | 191 | </span> |
| 192 | 192 | )} |
modified
apps/web/src/components/scanner/scanner-client.tsx
+1 −1
@@ -260,7 +260,7 @@ function ScanResults({ r, chosen, onChoose }: { r: ScanResponse; chosen: string | ||
| 260 | 260 | <CardHeader title="Parsed listing" subtitle={r.listing.sourceId} action={<a href={r.listing.sourceUrl} target="_blank" rel="noreferrer noopener" className="text-muted hover:text-fg">Open source ↗</a>} /> |
| 261 | 261 | <div className="grid gap-4 p-4 sm:grid-cols-3"> |
| 262 | 262 | <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} /> |
| 263 | − <Stat label="vs RareIndex Valuation" value={r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv < 0 ? 'text-gain' : 'text-loss'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', unknown: 'No valuation to compare' }[r.listing.verdict]} /> | |
| 263 | + <Stat label="vs RareIndex Valuation" value={r.listing.verdict === 'anomaly' ? <span className="rounded-sm bg-alert-bg px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-alert">Data/identity anomaly</span> : r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv <= -0.1 ? 'text-gain' : r.listing.discountToRiv >= 0.1 ? 'text-loss' : 'text-flat'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', anomaly: 'Implausible against the valuation — review the variant, lot, currency and identity', unknown: 'No valuation to compare' }[r.listing.verdict] ?? 'No valuation to compare'} /> | |
| 264 | 264 | <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} /> |
| 265 | 265 | </div> |
| 266 | 266 | </Card> |
modified
apps/web/src/components/ui/primitives.tsx
+25 −0
@@ -38,6 +38,31 @@ export function Delta({ value, digits = 2, className, arrow = false }: { value: | ||
| 38 | 38 | ); |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | +/** | |
| 42 | + * Ask vs RareIndex Valuation (§83–§84, §196). `discount` = (ask − RIV) / RIV as stored: negative = the ask | |
| 43 | + * is below the valuation. Anything implausible (ask < 10 % or > 10× RIV) is a data/identity anomaly and is | |
| 44 | + * labelled as such instead of being shown as a percentage — never as a "deal". | |
| 45 | + */ | |
| 46 | +export function VsRiv({ discount, className, digits = 1, showLabel = true }: { discount: number | null | undefined; className?: string; digits?: number; showLabel?: boolean }) { | |
| 47 | + if (discount === null || discount === undefined || !Number.isFinite(discount)) return <span className={cn('text-subtle', className)} title="No comparable valuation for this variant">—</span>; | |
| 48 | + const ratio = 1 + discount; | |
| 49 | + if (ratio < 0.1 || ratio > 10) { | |
| 50 | + return ( | |
| 51 | + <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="The asking price is implausible against this valuation (wrong variant, lot, currency or identity). Not a deal until reviewed."> | |
| 52 | + Data/identity anomaly | |
| 53 | + </span> | |
| 54 | + ); | |
| 55 | + } | |
| 56 | + 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'; | |
| 58 | + return ( | |
| 59 | + <span className={cn('num inline-flex items-baseline gap-1 font-medium', tone, className)} title={`Asking price ${fmtPct(Math.abs(discount), digits, false)} ${word}. Analytical data, not advice.`}> | |
| 60 | + {fmtPct(discount, digits)} | |
| 61 | + {showLabel ? <span className="text-[0.85em] font-normal text-subtle">vs RIV</span> : null} | |
| 62 | + </span> | |
| 63 | + ); | |
| 64 | +} | |
| 65 | + | |
| 41 | 66 | export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) { |
| 42 | 67 | const tones: Record<string, string> = { |
| 43 | 68 | neutral: 'bg-inset text-muted', |
modified
apps/web/src/lib/ai/request.ts
+8 −8
@@ -2,7 +2,7 @@ import 'server-only'; | ||
| 2 | 2 | import { createHmac, randomUUID } from 'node:crypto'; |
| 3 | 3 | import { cookies, headers } from 'next/headers'; |
| 4 | 4 | import { getCurrentUser } from '@/lib/auth/session'; |
| 5 | −import { getDb, aiQuotas, sql } from '@rareindex/database'; | |
| 5 | +import { getDb, sql } from '@rareindex/database'; | |
| 6 | 6 | |
| 7 | 7 | const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only'; |
| 8 | 8 | |
@@ -56,13 +56,13 @@ export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId: | ||
| 56 | 56 | const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous; |
| 57 | 57 | const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`; |
| 58 | 58 | const date = new Date().toISOString().slice(0, 10); |
| 59 | − const db = getDb(); | |
| 60 | − const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`); | |
| 61 | − const used = Number((row as { count?: number } | undefined)?.count ?? 0); | |
| 62 | − if (used >= limit) return { ok: false, used, limit }; | |
| 63 | − if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`); | |
| 64 | − else await db.insert(aiQuotas).values({ key, feature, date, count: 1 }); | |
| 65 | − return { ok: true, used: used + 1, limit }; | |
| 59 | + // Atomic increment (unique (key, feature, date)): concurrent requests can no longer create | |
| 60 | + // duplicate counters and slip past the limit. | |
| 61 | + const [row] = await getDb().execute(sql`insert into ai_quotas (key, feature, date, count) values (${key}, ${feature}, ${date}, 1) | |
| 62 | + on conflict (key, feature, date) do update set count = ai_quotas.count + 1 returning count`); | |
| 63 | + const used = Number((row as { count?: number } | undefined)?.count ?? 1); | |
| 64 | + if (used > limit) return { ok: false, used: used - 1, limit }; | |
| 65 | + return { ok: true, used, limit }; | |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | export function aiErrorMessage(err: unknown): { status: number; message: string } { |
modified
apps/web/src/lib/ai/research-tools.ts
+2 −1
@@ -203,11 +203,12 @@ export async function executeTool(name: string, rawInput: unknown): Promise<unkn | ||
| 203 | 203 | const i = input as z.infer<typeof TOOL_SCHEMAS.screen>; |
| 204 | 204 | const col = sql.raw(`s.${WINDOWS[i.window as Window]}`); |
| 205 | 205 | const sortCol = { change: col, value: sql`s.riv_usd`, liquidity: sql`s.liquidity_score`, sales: sql`s.sales_count`, trending: sql`s.trending_score`, opportunity: sql`s.value_opportunity` }[i.sort as string] ?? col; |
| 206 | + const sortDir = i.sort === 'opportunity' ? sql`ASC NULLS LAST` : sql`DESC NULLS LAST`; // opportunity: most below RIV first | |
| 206 | 207 | const rows = await run(sql`select ${ASSET_COLS} from assets a join asset_stats s on s.asset_id = a.id where s.riv_sample_size >= ${i.min_sales} ${scope(i.category)} |
| 207 | 208 | ${i.min_price_usd != null ? sql`and s.riv_usd >= ${i.min_price_usd}` : sql``} ${i.max_price_usd != null ? sql`and s.riv_usd <= ${i.max_price_usd}` : sql``} |
| 208 | 209 | ${i.min_change != null ? sql`and ${col} >= ${i.min_change}` : sql``} ${i.max_change != null ? sql`and ${col} <= ${i.max_change}` : sql``} |
| 209 | 210 | ${i.max_sales_1y != null ? sql`and s.sales_1y <= ${i.max_sales_1y}` : sql``} ${i.min_liquidity != null ? sql`and s.liquidity_score >= ${i.min_liquidity}` : sql``} |
| 210 | − order by ${sortCol} desc nulls last limit ${i.limit}`); | |
| 211 | + order by ${sortCol} ${sortDir} limit ${i.limit}`); | |
| 211 | 212 | return { filters: i, count: rows.length, results: rows }; |
| 212 | 213 | } |
| 213 | 214 | case 'record_sales': { |
modified
apps/web/src/lib/ai/research.ts
+3 −1
@@ -48,7 +48,9 @@ export async function loadSession(sessionId: string, owner: { anonId: string | n | ||
| 48 | 48 | const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1); |
| 49 | 49 | if (!s) return null; |
| 50 | 50 | if (s.userId && s.userId !== owner.userId) return null; |
| 51 | − if (!s.userId && s.anonId && s.anonId !== owner.anonId) return null; | |
| 51 | + // anonymous threads are readable only by the browser that created them; a thread without an | |
| 52 | + // anon id (cookie could not be set) is not readable by anyone afterwards | |
| 53 | + if (!s.userId && (!owner.anonId || s.anonId !== owner.anonId)) return null; | |
| 52 | 54 | const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt); |
| 53 | 55 | return { session: s, messages }; |
| 54 | 56 | } |
modified
apps/web/src/lib/ai/scanner.ts
+21 −5
@@ -5,6 +5,8 @@ import { getGrader, parseGradeFromTitle } from '@rareindex/taxonomy'; | ||
| 5 | 5 | import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai'; |
| 6 | 6 | import { lookupViaApi } from './lookup'; |
| 7 | 7 | import { findCandidates, assetContext, type Candidate } from './candidates'; |
| 8 | +import { assessAsk } from '@rareindex/valuation'; | |
| 9 | +import { toUsdAt } from '@/lib/account/fx'; | |
| 8 | 10 | |
| 9 | 11 | export interface ScanResult { |
| 10 | 12 | sessionId: string; |
@@ -33,7 +35,7 @@ export interface ListingSummary { | ||
| 33 | 35 | identifiers: Record<string, string>; |
| 34 | 36 | /** price vs RIV when both known: negative = below fair value */ |
| 35 | 37 | discountToRiv: number | null; |
| 36 | − verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'unknown'; | |
| 38 | + verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'anomaly' | 'unknown'; | |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | 41 | interface ScanInput { |
@@ -104,9 +106,16 @@ export async function runScan(input: ScanInput): Promise<ScanResult> { | ||
| 104 | 106 | const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null; |
| 105 | 107 | if (!best) notes.push(candidates.length ? 'Low-confidence match: review the candidates below.' : 'No matching asset in the RareIndex catalog yet. The pipeline adds assets as connectors ingest sources.'); |
| 106 | 108 | |
| 107 | − if (listing && best?.rivUsd && listing.price !== null && listing.currency === 'USD') { | |
| 108 | − listing.discountToRiv = (listing.price - best.rivUsd) / best.rivUsd; | |
| 109 | − listing.verdict = listing.discountToRiv <= -0.15 ? 'below_fair_value' : listing.discountToRiv >= 0.2 ? 'above_fair_value' : 'in_range'; | |
| 109 | + if (listing && best?.rivUsd && listing.price !== null && listing.currency) { | |
| 110 | + // Compare in USD at today's ECB rate; gate exactly like the valuation worker (transaction-based RIV, | |
| 111 | + // ≥ 5 sales, medium+ confidence) and refuse implausible ratios instead of calling them deals. | |
| 112 | + const fx = listing.currency === 'USD' ? { usd: listing.price } : await toUsdAt(listing.price, listing.currency, null); | |
| 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 }); | |
| 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'; | |
| 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 | + else if (a.verdict === 'ungated') notes.push(`The ask was not compared with the valuation (${a.reasons.join(', ')}).`); | |
| 110 | 119 | } |
| 111 | 120 | |
| 112 | 121 | const durationMs = Date.now() - started; |
@@ -134,8 +143,15 @@ export async function runScan(input: ScanInput): Promise<ScanResult> { | ||
| 134 | 143 | return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes }; |
| 135 | 144 | } |
| 136 | 145 | |
| 137 | −export async function chooseCandidate(sessionId: string, assetId: string | null): Promise<void> { | |
| 146 | +/** Record the user's confirmation of a candidate. Only the session's owner (user, anon cookie or, failing both, the same IP hash) may write it. */ | |
| 147 | +export async function chooseCandidate(sessionId: string, assetId: string | null, owner: { userId: string | null; anonId: string | null; ipHash: string }): Promise<boolean> { | |
| 148 | + const [s] = await getDb().select({ userId: scannerSessions.userId, anonId: scannerSessions.anonId, ipHash: scannerSessions.ipHash, candidates: scannerSessions.candidates }).from(scannerSessions).where(eq(scannerSessions.id, sessionId)).limit(1); | |
| 149 | + if (!s) return false; | |
| 150 | + const owns = s.userId ? s.userId === owner.userId : s.anonId ? s.anonId === owner.anonId : s.ipHash === owner.ipHash; | |
| 151 | + if (!owns) return false; | |
| 152 | + if (assetId !== null && !s.candidates.some((c) => c.assetId === assetId)) return false; // only a proposed candidate can be chosen | |
| 138 | 153 | await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId)); |
| 154 | + return true; | |
| 139 | 155 | } |
| 140 | 156 | |
| 141 | 157 | /** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */ |
modified
apps/web/src/lib/queries/assets.ts
+8 −4
@@ -135,7 +135,7 @@ const SORTS: Record<ExploreSort, SQL> = { | ||
| 135 | 135 | trending: sql`s.trending_score DESC NULLS LAST`, |
| 136 | 136 | newest: sql`a.created_at DESC`, |
| 137 | 137 | latest_sale: sql`s.latest_sale_at DESC NULLS LAST`, |
| 138 | − opportunity: sql`s.value_opportunity DESC NULLS LAST`, | |
| 138 | + opportunity: sql`s.value_opportunity ASC NULLS LAST`, // most below RIV first | |
| 139 | 139 | name: sql`a.name ASC`, |
| 140 | 140 | number: sql`a.set_name ASC NULLS LAST, (substring(a.number from '^[0-9]+'))::int ASC NULLS LAST, a.number ASC NULLS LAST`, |
| 141 | 141 | }; |
@@ -193,13 +193,17 @@ export async function rankedAssets(kind: RankedKind, opts: { scope?: string[]; l | ||
| 193 | 193 | const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`; |
| 194 | 194 | const spec: Record<typeof kind, { where: SQL; order: SQL }> = { |
| 195 | 195 | trending: { where: sql`s.trending_score IS NOT NULL`, order: sql`s.trending_score DESC` }, |
| 196 | − gainers: { where: sql`${chg} IS NOT NULL AND ${chg} > 0 AND s.riv_sample_size >= 3`, order: sql`${chg} DESC` }, | |
| 197 | − losers: { where: sql`${chg} IS NOT NULL AND ${chg} < 0 AND s.riv_sample_size >= 3`, order: sql`${chg} ASC` }, | |
| 196 | + // Movers need a valuation that can move: ≥ 5 transactions, medium confidence, a plausible move (≤ ±500 %) and | |
| 197 | + // at least one sale in the last year (a stale series cannot "move"). | |
| 198 | + gainers: { where: sql`${chg} IS NOT NULL AND ${chg} > 0 AND ${chg} <= 5 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5 AND coalesce(s.sales_1y, 0) >= 3`, order: sql`${chg} DESC` }, | |
| 199 | + losers: { where: sql`${chg} IS NOT NULL AND ${chg} < 0 AND ${chg} >= -0.99 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5 AND coalesce(s.sales_1y, 0) >= 3`, order: sql`${chg} ASC` }, | |
| 198 | 200 | volume: { where: sql`s.volume_30d_usd IS NOT NULL AND s.volume_30d_usd > 0`, order: sql`s.volume_30d_usd DESC` }, |
| 199 | 201 | watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` }, |
| 200 | 202 | newest: { where: sql`true`, order: sql`a.created_at DESC` }, |
| 201 | 203 | expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` }, |
| 202 | − opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity > 0`, order: sql`s.value_opportunity DESC` }, | |
| 204 | + // value_opportunity = (best gated ask − RIV) / RIV: negative = below the valuation. A deal is ≤ −10 %; the | |
| 205 | + // worker never stores implausible ratios, the ≥ −0.9 bound is a second safety net (§84, §196). | |
| 206 | + 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` }, | |
| 203 | 207 | liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` }, |
| 204 | 208 | // Fallback rails used while valuations are still being computed (§192: honest, data-backed) |
| 205 | 209 | 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 −2
@@ -97,14 +97,14 @@ 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 >= ${f.minDiscount}`); | |
| 100 | + if (f.minDiscount != null) where.push(sql`l.discount_to_riv <= ${-Math.abs(f.minDiscount)}`); // stored negative = below RIV | |
| 101 | 101 | if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`); |
| 102 | 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 | 103 | const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = { |
| 104 | 104 | newest: sql`l.first_seen_at DESC`, |
| 105 | 105 | price_asc: sql`l.price_usd ASC NULLS LAST`, |
| 106 | 106 | price_desc: sql`l.price_usd DESC NULLS LAST`, |
| 107 | − discount: sql`l.discount_to_riv DESC NULLS LAST`, | |
| 107 | + discount: sql`l.discount_to_riv ASC NULLS LAST`, // most below RIV first | |
| 108 | 108 | ending: sql`l.ends_at ASC NULLS LAST`, |
| 109 | 109 | }; |
| 110 | 110 | const r = await rows<Record<string, unknown>>(sql` |
added
packages/database/migrations/0006_skinny_kulan_gath.sql
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +DROP INDEX "ai_quotas_uq";--> statement-breakpoint | |
| 2 | +-- collapse duplicate daily counters created by the former non-atomic read-then-write before the key becomes unique | |
| 3 | +DELETE FROM "ai_quotas" a USING "ai_quotas" b WHERE a.key = b.key AND a.feature = b.feature AND a.date = b.date AND a.ctid < b.ctid;--> statement-breakpoint | |
| 4 | +CREATE INDEX "asset_stats_change_7d_idx" ON "asset_stats" USING btree ("change_7d");--> statement-breakpoint | |
| 5 | +CREATE INDEX "asset_stats_change_30d_idx" ON "asset_stats" USING btree ("change_30d");--> statement-breakpoint | |
| 6 | +CREATE INDEX "asset_stats_change_1y_idx" ON "asset_stats" USING btree ("change_1y");--> statement-breakpoint | |
| 7 | +CREATE INDEX "asset_stats_opportunity_idx" ON "asset_stats" USING btree ("value_opportunity") WHERE value_opportunity is not null;--> statement-breakpoint | |
| 8 | +CREATE INDEX "asset_stats_sales_30d_idx" ON "asset_stats" USING btree ("sales_30d");--> statement-breakpoint | |
| 9 | +CREATE INDEX "variant_stats_asset_idx" ON "variant_stats" USING btree ("asset_id");--> statement-breakpoint | |
| 10 | +CREATE INDEX "listings_variant_avail_idx" ON "listings" USING btree ("variant_id","availability");--> statement-breakpoint | |
| 11 | +CREATE INDEX "listings_discount_idx" ON "listings" USING btree ("discount_to_riv") WHERE discount_to_riv is not null and availability = 'available';--> statement-breakpoint | |
| 12 | +CREATE INDEX "sales_valid_date_idx" ON "sales" USING btree ("sale_date") WHERE status = 'valid';--> statement-breakpoint | |
| 13 | +CREATE UNIQUE INDEX "ai_quotas_uq" ON "ai_quotas" USING btree ("key","feature","date"); | |
| \ No newline at end of file | ||
added
packages/database/migrations/meta/0006_snapshot.json
+9035 −0
Large diff (9,036 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/migrations/meta/_journal.json
+7 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/src/schema/ai.ts
+2 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/src/schema/assets.ts
+12 −2
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/src/schema/market.ts
+5 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/taxonomy/src/index.ts
+18 −6
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/taxonomy/src/taxonomy.test.ts
+12 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/valuation/src/scores.ts
+106 −5
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/valuation/src/valuation.test.ts
+38 −1
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
modified
pnpm-lock.yaml
+3 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified
workers/cli.ts
+20 −7
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added
workers/entity-resolution/regrade.ts
+107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
modified
workers/entity-resolution/writers.ts
+37 −17
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified
workers/indices/run.ts
+7 −4
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
modified
workers/valuation/run.ts
+48 −18
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff