SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%

Auction intelligence (workers/API): buyer-pays price on sales (saleAllIn, RIV/ATH/premiums/index stats on coalesce(all_in_usd, price_usd)), fees backfill CLI, lot assessment worker (auctions.assess every 20 min: USD, all-in bid/estimate, bid vs RIV with the same gates as asks), radar kind auction_below_riv, alert auction_below_riv, GET /v1/auctions/lots + /v1/assets/:id/auctions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent b5d6968

22 changed files +549 −13

modified apps/api/src/lib/queries.ts +30 −2
@@ -57,7 +57,7 @@ export async function getAsset(idOrSlug: string) {
57 57 export async function assetSales(assetId: string, opts: { limit: number; offset: number; variantId?: string; includeFlagged?: boolean }) {
58 58 const statusFilter = opts.includeFlagged ? sql`and status <> 'excluded'` : sql`and status = 'valid'`;
59 59 const variantFilter = opts.variantId ? sql`and variant_id = ${opts.variantId}` : sql``;
60 − return rows(sql`select id, variant_id, source_id, source_url, sale_type, sale_date, price, currency, price_usd, buyer_premium_included, condition, grader, grade, certification_number, auction_house, lot_number, image_urls, raw_title, confidence, data_quality, status, flags
60 + return rows(sql`select id, variant_id, source_id, source_url, sale_type, sale_date, price, currency, price_usd, buyer_premium_included, all_in_usd, fee_basis, buyer_premium_rate, condition, grader, grade, certification_number, auction_house, lot_number, image_urls, raw_title, confidence, data_quality, status, flags
61 61 from sales where asset_id = ${assetId} ${statusFilter} ${variantFilter} order by sale_date desc limit ${opts.limit} offset ${opts.offset}`);
62 62 }
63 63
@@ -133,7 +133,7 @@ export async function trending(opts: { category?: string; limit: number; offset:
133 133 export async function latestSales(opts: { category?: string; minUsd?: number; limit: number; offset: number }) {
134 134 const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``;
135 135 const min = opts.minUsd ? sql`and sa.price_usd >= ${opts.minUsd}` : sql``;
136 − return rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, a.category_slug, a.hero_image_url, sa.source_id, sa.source_url, sa.sale_type, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.condition, sa.auction_house, sa.confidence
136 + return rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, a.category_slug, a.hero_image_url, sa.source_id, sa.source_url, sa.sale_type, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.all_in_usd, sa.fee_basis, sa.grader, sa.grade, sa.condition, sa.auction_house, sa.confidence
137 137 from sales sa join assets a on a.id = sa.asset_id where sa.status = 'valid' ${cat} ${min} order by sa.sale_date desc, sa.created_at desc limit ${opts.limit} offset ${opts.offset}`);
138 138 }
139 139
@@ -214,3 +214,31 @@ function n(v: unknown): number | null {
214 214 const x = Number(v);
215 215 return Number.isFinite(x) ? x : null;
216 216 }
217 +
218 +/**
219 + * Auction lots with their all-in assessment (§33–§35). Amounts: native (`currency`), USD at the
220 + * assessment-date rate, and buyer-pays (`all_in_*`, hammer + house premium per `fee_basis`).
221 + * `bid_vs_riv` / `estimate_vs_riv` follow the listing convention: (all-in − RIV) / RIV, negative =
222 + * below the valuation; null when the comparison did not pass the gates (`assessment_verdict`).
223 + * Taxes, duties and shipping are not included.
224 + */
225 +export const LOT_COLUMNS = sql`l.id, l.auction_id, au.auction_house, au.name as auction_name, au.url as auction_url, l.asset_id, a.slug as asset_slug, a.title as asset_title, a.category_slug, l.variant_id, l.lot_number, l.title, l.url, l.source_id,
226 + l.estimate_low, l.estimate_high, l.current_bid, l.hammer_price, l.currency, l.bid_count, l.starts_at, l.ends_at, l.status, l.grader, l.grade, l.image_urls,
227 + l.estimate_low_usd, l.estimate_high_usd, l.current_bid_usd, l.hammer_price_usd, l.fx_rate, l.fx_date,
228 + l.buyer_premium_rate, l.fee_basis, l.all_in_bid_usd, l.all_in_estimate_low_usd, l.all_in_estimate_high_usd,
229 + l.riv_usd_at_assessment as riv_usd, l.bid_vs_riv, l.estimate_vs_riv, l.assessment_verdict, l.assessed_at`;
230 +
231 +export async function auctionLots(opts: { status?: string; endingWithinHours?: number; category?: string; house?: string; belowRiv?: boolean; assetId?: string; sort?: 'ending' | 'discount'; limit: number; offset: number }) {
232 + const w = [sql`true`];
233 + if (opts.status) w.push(sql`l.status = ${opts.status}`);
234 + else w.push(sql`l.status in ('live','upcoming')`);
235 + if (opts.endingWithinHours) w.push(sql`l.ends_at between now() and now() + (${opts.endingWithinHours}::int || ' hours')::interval`);
236 + if (opts.category) w.push(sql`(a.category_slug = ${opts.category} or a.family_slug = ${opts.category})`);
237 + if (opts.house) w.push(sql`lower(au.auction_house) = lower(${opts.house})`);
238 + if (opts.belowRiv) w.push(sql`l.assessment_verdict = 'deal' and l.bid_vs_riv is not null`);
239 + if (opts.assetId) w.push(sql`l.asset_id = ${opts.assetId}`);
240 + const where = sql.join(w, sql` and `);
241 + const order = opts.sort === 'discount' ? sql`coalesce(l.bid_vs_riv, l.estimate_vs_riv) asc nulls last, l.ends_at asc nulls last` : sql`l.ends_at asc nulls last`;
242 + return rows(sql`select ${LOT_COLUMNS} from auction_lots l join auctions au on au.id = l.auction_id left join assets a on a.id = l.asset_id
243 + where ${where} order by ${order} limit ${opts.limit} offset ${opts.offset}`);
244 +}
modified apps/api/src/openapi.ts +4 −0
@@ -44,6 +44,7 @@ export function openapiDocument() {
44 44 { name: 'indices' },
45 45 { name: 'sales' },
46 46 { name: 'reference' },
47 + { name: 'auctions' },
47 48 ],
48 49 paths: {
49 50 '/v1/assets/search': { get: { tags: ['assets'], summary: 'Search canonical assets', parameters: [{ name: 'q', in: 'query', schema: { type: 'string' }, description: 'Natural language query, e.g. "1999 Charizard PSA 10"' }, { name: 'category', in: 'query', schema: { type: 'string' }, description: 'Category or family slug' }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } },
@@ -59,6 +60,8 @@ export function openapiDocument() {
59 60 '/v1/markets/{slug}': { get: { tags: ['markets'], summary: 'Category market detail: movers, most valuable, most liquid, recent sales, history', parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' }, example: 'pokemon' }], responses: { ...ok('#/components/schemas/MarketDetail', false), ...errors } } },
60 61 '/v1/trending': { get: { tags: ['markets'], summary: 'Trending assets', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } },
61 62 '/v1/sales/latest': { get: { tags: ['sales'], summary: 'Latest observed sales across the platform', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, { name: 'min_usd', in: 'query', schema: { type: 'number' } }, ...paging], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } },
63 + '/v1/assets/{id}/auctions': { get: { tags: ['assets', 'auctions'], summary: 'Auction lots for an asset with all-in (buyer-pays) assessment vs RIV', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'status', in: 'query', schema: { type: 'string', enum: ['live', 'upcoming', 'ended'] } }, ...paging], responses: { ...ok('#/components/schemas/AuctionLot'), ...errors } } },
64 + '/v1/auctions/lots': { get: { tags: ['auctions'], summary: 'Live/upcoming auction lots across houses with all-in bid or estimate vs RIV (§33–§35)', parameters: [{ name: 'status', in: 'query', schema: { type: 'string', enum: ['live', 'upcoming', 'ended'] } }, { name: 'ending_within_hours', in: 'query', schema: { type: 'integer', minimum: 1 } }, { name: 'category', in: 'query', schema: { type: 'string' } }, { name: 'house', in: 'query', schema: { type: 'string' } }, { name: 'below_riv', in: 'query', schema: { type: 'boolean' }, description: 'Only lots whose buyer-pays bid is ≥ 10 % below the variant RIV (verdict deal)' }, { name: 'asset', in: 'query', schema: { type: 'string' }, description: 'Asset id or slug' }, { name: 'sort', in: 'query', schema: { type: 'string', enum: ['ending', 'discount'] } }, ...paging], responses: { ...ok('#/components/schemas/AuctionLot'), ...errors } } },
62 65 '/v1/records': { get: { tags: ['sales'], summary: 'Record sale per family (verified transactions only)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } },
63 66 '/v1/stats': { get: { tags: ['reference'], summary: 'Platform counts', responses: { ...ok('#/components/schemas/Stats', false) } } },
64 67 },
@@ -84,6 +87,7 @@ export function openapiDocument() {
84 87 Index: { type: 'object', properties: { ticker: { type: 'string' }, name: { type: 'string' }, is_flagship: { type: 'boolean' }, as_of: { type: ['string', 'null'] }, value: { type: ['number', 'null'] }, published: { type: 'boolean' }, change_1d: { type: ['number', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_ytd: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, constituents_count: { type: ['integer', 'null'] }, transactions: { type: ['integer', 'null'] }, market_cap_est_usd: { type: ['number', 'null'] }, market_cap_confidence: { type: ['string', 'null'] } } },
85 88 IndexPoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, value: { type: 'number' }, constituents_count: { type: 'integer' }, transactions: { type: 'integer' }, volume_usd: { type: ['number', 'null'] } } },
86 89 Market: { type: 'object', properties: { slug: { type: 'string' }, name: { type: 'string' }, as_of: { type: ['string', 'null'] }, index_value: { type: ['number', 'null'] }, tracked_assets: { type: ['integer', 'null'] }, sales: { type: ['integer', 'null'] }, volume_usd: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] } } },
90 + AuctionLot: { type: 'object', properties: { id: { type: 'string' }, auction_id: { type: 'string' }, auction_house: { type: 'string' }, auction_name: { type: ['string', 'null'] }, asset_id: { type: ['string', 'null'] }, asset_slug: { type: ['string', 'null'] }, asset_title: { type: ['string', 'null'] }, variant_id: { type: ['string', 'null'] }, lot_number: { type: ['string', 'null'] }, title: { type: 'string' }, url: { type: 'string' }, estimate_low: { type: ['number', 'null'] }, estimate_high: { type: ['number', 'null'] }, current_bid: { type: ['number', 'null'] }, hammer_price: { type: ['number', 'null'] }, currency: { type: ['string', 'null'] }, bid_count: { type: ['integer', 'null'] }, ends_at: { type: ['string', 'null'] }, status: { type: 'string' }, grader: { type: ['string', 'null'] }, grade: { type: ['string', 'null'] }, estimate_low_usd: { type: ['number', 'null'] }, estimate_high_usd: { type: ['number', 'null'] }, current_bid_usd: { type: ['number', 'null'] }, hammer_price_usd: { type: ['number', 'null'] }, fx_rate: { type: ['number', 'null'] }, fx_date: { type: ['string', 'null'] }, buyer_premium_rate: { type: ['number', 'null'], description: 'Effective buyer premium applied (0.27 = 27 %)' }, fee_basis: { type: ['string', 'null'], enum: ['included', 'added_published', 'added_approximate', 'added_default', 'none', 'unknown', null] }, all_in_bid_usd: { type: ['number', 'null'], description: 'Current bid + premium (only when ≥ 1 bid)' }, all_in_estimate_low_usd: { type: ['number', 'null'] }, all_in_estimate_high_usd: { type: ['number', 'null'] }, riv_usd: { type: ['number', 'null'], description: 'RIV of the lot’s variant at assessment time' }, bid_vs_riv: { type: ['number', 'null'], description: '(all-in bid − RIV) / RIV; negative = below RIV; null when ungated' }, estimate_vs_riv: { type: ['number', 'null'] }, assessment_verdict: { type: ['string', 'null'], enum: ['deal', 'fair', 'premium', 'review', 'anomaly', 'ungated', null] }, assessed_at: { type: ['string', 'null'] } } },
87 91 AssetDepth: { type: 'object', properties: { asset_id: { type: 'string' }, variant_id: { type: ['string', 'null'] }, scope: { type: 'string', enum: ['variant', 'asset'] }, riv_usd: { type: ['number', 'null'] }, depth: { type: ['object', 'null'], properties: { asks: { type: 'integer' }, within5: { type: 'integer' }, within10: { type: 'integer' }, within20: { type: 'integer' }, belowRiv: { type: 'integer' }, aboveRiv: { type: 'integer' }, lowestAsk: { type: ['number', 'null'] }, medianAsk: { type: ['number', 'null'] }, askRivSpread: { type: ['number', 'null'], description: '(best ask − RIV) / RIV' } } }, liquidation: { type: ['object', 'null'], properties: { level: { type: 'string', enum: ['asset', 'category'] }, lifecycles: { type: 'integer' }, sold: { type: 'object' }, withdrawn: { type: 'object' }, bands: { type: 'array', items: { type: 'object' } }, sellThrough: { type: ['number', 'null'] } } }, fair_prices: { type: ['object', 'null'] }, note: { type: 'string' } } },
88 92 MarketDetail: { type: 'object', properties: { category: { type: 'object' }, snapshot: { type: ['object', 'null'] }, counts: { type: 'object' }, gainers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, losers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_valuable: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_liquid: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, recent_sales: { type: 'array', items: { type: 'object' } }, history: { type: 'array', items: { type: 'object' } } } },
89 93 Stats: { type: 'object', properties: { assets: { type: 'integer' }, sales: { type: 'integer' }, listings: { type: 'integer' }, sources: { type: 'integer' }, connectors: { type: 'integer' }, categories: { type: 'integer' } } },
modified apps/api/src/routes/v1.ts +27 −0
@@ -66,6 +66,33 @@ export async function v1Routes(app: FastifyInstance) {
66 66 return reply.send(envelope(data, { asset_id: asset.id }));
67 67 });
68 68
69 + app.get<{ Params: { id: string } }>('/v1/assets/:id/auctions', async (req, reply) => {
70 + const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).optional() }), req.query);
71 + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
72 + const asset = await q.getAsset(req.params.id);
73 + if (!asset) return problem(reply, 404, 'Asset not found');
74 + const offset = decodeCursor(p.data.cursor);
75 + const rows = await q.auctionLots({ assetId: asset.id as string, status: p.data.status, limit: p.data.limit + 1, offset });
76 + const page = rows.slice(0, p.data.limit);
77 + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'all_in_* = hammer + estimated buyer premium (see fee_basis); taxes, duties and shipping excluded. Bids are not confirmed transactions.' }, req.query as Record<string, unknown>);
78 + });
79 +
80 + app.get('/v1/auctions/lots', async (req, reply) => {
81 + const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).optional(), ending_within_hours: z.coerce.number().int().min(1).max(24 * 90).optional(), category: z.string().max(80).optional(), house: z.string().max(120).optional(), below_riv: z.coerce.boolean().optional(), asset: z.string().max(80).optional(), sort: z.enum(['ending', 'discount']).optional() }), req.query);
82 + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
83 + const offset = decodeCursor(p.data.cursor);
84 + let assetId: string | undefined;
85 + if (p.data.asset) {
86 + const asset = await q.getAsset(p.data.asset);
87 + if (!asset) return problem(reply, 404, 'Asset not found');
88 + assetId = asset.id as string;
89 + }
90 + const rows = await q.auctionLots({ status: p.data.status, endingWithinHours: p.data.ending_within_hours, category: p.data.category, house: p.data.house, belowRiv: p.data.below_riv, assetId, sort: p.data.sort, limit: p.data.limit + 1, offset });
91 + const page = rows.slice(0, p.data.limit);
92 + reply.header('cache-control', 'public, max-age=60, s-maxage=120');
93 + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, note: 'all_in_* = hammer + estimated buyer premium (see fee_basis); taxes, duties and shipping excluded. bid_vs_riv = (all-in bid − RIV) / RIV, null when the comparison did not pass the gates. Analytical data, not advice.' }, req.query as Record<string, unknown>);
94 + });
95 +
69 96 app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => {
70 97 const p = parse(Range.extend({ variant: z.string().optional() }), req.query);
71 98 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
modified apps/web/src/app/(account)/alerts/alert-form.tsx +1 −0
@@ -13,6 +13,7 @@ const TYPES: Array<{ v: string; l: string; targets: Array<'asset' | 'category' |
13 13 { v: 'new_listing', l: 'New listing appears', targets: ['asset'] },
14 14 { v: 'new_auction', l: 'New auction lot', targets: ['asset', 'category'] },
15 15 { v: 'auction_ending', l: 'Auction ending within 24h', targets: ['asset', 'category'] },
16 + { v: 'auction_below_riv', l: 'Auction bid (with buyer premium) ≥ 10 % below RIV', targets: ['asset'] },
16 17 { v: 'record_sale', l: 'New record (all-time high) sale', targets: ['asset', 'category'] },
17 18 { v: 'unusual_volume', l: 'Unusual sales volume (% above 90-day average)', targets: ['asset', 'category'], threshold: 'pct' },
18 19 { v: 'market_move', l: 'Index / category moves more than %', targets: ['category', 'index'], threshold: 'pct' },
modified apps/web/src/app/(account)/alerts/page.tsx +1 −0
@@ -17,6 +17,7 @@ const ALERT_LABELS: Record<string, string> = {
17 17 new_listing: 'New listing',
18 18 new_auction: 'New auction lot',
19 19 auction_ending: 'Auction ending soon',
20 + auction_below_riv: 'Auction bid below RIV (all-in)',
20 21 record_sale: 'New record sale',
21 22 unusual_volume: 'Unusual volume',
22 23 market_move: 'Market move',
modified apps/web/src/lib/account/actions.ts +1 −1
@@ -364,7 +364,7 @@ export async function updateWatchItemAction(_prev: ActionState, formData: FormDa
364 364
365 365 // ---------------------------------------------------------------- alerts
366 366
367 −const ALERT_TYPES = ['price_below', 'price_above', 'new_listing', 'new_auction', 'auction_ending', 'record_sale', 'unusual_volume', 'market_move', 'rare_item', 'population_update'] as const;
367 +const ALERT_TYPES = ['price_below', 'price_above', 'new_listing', 'new_auction', 'auction_ending', 'auction_below_riv', 'record_sale', 'unusual_volume', 'market_move', 'rare_item', 'population_update'] as const;
368 368 const alertSchema = z.object({
369 369 alertType: z.enum(ALERT_TYPES),
370 370 targetType: z.enum(['asset', 'category', 'index']),
modified docs/API.md +3 −1
@@ -43,10 +43,12 @@ carries `x-request-id`. Usage is counted per key/day/endpoint in `api_usage`.
43 43 |---|---|
44 44 | `GET /v1/assets/search?q&category&limit&cursor` | Search canonical assets (FTS + trigram + identifiers) |
45 45 | `GET /v1/assets/{idOrSlug}` | Asset detail: attributes, stats, variants with their own valuations, latest valuation, sources |
46 −| `GET /v1/assets/{id}/sales?variant&include_flagged` | Observed sales (valid only by default) |
46 +| `GET /v1/assets/{id}/sales?variant&include_flagged` | Observed sales (valid only by default); `all_in_usd` = buyer-pays price (hammer + premium per `fee_basis`) |
47 47 | `GET /v1/assets/{id}/listings?availability` | Listings (asks) with `discount_to_riv` |
48 48 | `GET /v1/assets/{id}/history?variant&from&to` | Daily series: RIV, latest sale, median, sales count, volume, listings, min ask |
49 49 | `GET /v1/assets/{id}/depth?variant` | Market depth (asks within ±5/10/20 % of RIV, below/above, best & median ask, RareIndex spread), days on market (sold vs withdrawn), median time-to-sale by asking band (≤ 90 %, 90–100 %, 100–110 %, > 110 % of RIV; null under 5 observations), fair buy/sell ladder. `liquidation.level` is `category` when the asset has < 10 completed lifecycles. Model estimates, not advice |
50 +| `GET /v1/assets/{id}/auctions?status` | Auction lots for an asset with native, USD and all-in (buyer-pays) amounts, `fee_basis`, `bid_vs_riv` / `estimate_vs_riv` and `assessment_verdict` |
51 +| `GET /v1/auctions/lots?status&ending_within_hours&category&house&below_riv&asset&sort` | Live/upcoming lots across houses: current bid or low estimate + the house's buyer premium (published, ≈ estimated or default 22 %) compared with the RIV of the lot's own variant through the ask gates. `below_riv=true` keeps verdict `deal` only. Taxes, duties and shipping excluded; bids are not transactions |
50 52 | `GET /v1/categories` | Taxonomy with tracked asset counts |
51 53 | `GET /v1/indices` | RARE + subindices: latest value, 1d/7d/30d/YTD/1y changes, breadth, market cap estimate + confidence; `published=false` until breadth threshold |
52 54 | `GET /v1/indices/{ticker}/history?from&to` | Index history |
modified docs/METHODOLOGY.md +34 −0
@@ -69,6 +69,24 @@ For each category, grader and grade, RareIndex measures the median ratio between
69 69 sales of the same assets (at least eight paired assets). PSA 10, BGS 10 and CGC 10 are never assumed
70 70 equivalent; premiums are only ever derived from paired evidence.
71 71
72 +### Buyer premiums and the buyer-pays price
73 +
74 +A hammer price and a marketplace price are not the same number: the buyer of an auction lot also
75 +pays the house's buyer premium. Every sale therefore carries `all_in_usd` = `price_usd` plus the
76 +premium when the record is hammer-only (`buyer_premium_included = false`, or unknown for a listed
77 +auction house), computed from the schedule in `data/fees/auction-houses.json` with marginal tiers,
78 +minimums, caps and fixed fees, tier thresholds converted into the schedule currency at the sale-date
79 +rate. `fee_basis` records how the figure was obtained — `included`, `added_published`,
80 +`added_approximate`, `added_default` (house unknown, 22 %), `none` (no premium: marketplaces,
81 +ComicLink, Yahoo! Auctions Japan…) or `unknown`. RIV, ATH/ATL, grade premiums, index volumes and
82 +the auction assessments all use `coalesce(all_in_usd, price_usd)`; the native `price` and
83 +`price_usd` are never overwritten. When a valuation's inputs include estimated premiums the
84 +valuation says so ("n % of inputs include an estimated buyer premium"). Live lots are assessed the
85 +same way: current bid (only when at least one bid exists) or low estimate + premium → all-in cost,
86 +compared with the RIV of the lot's own variant through the ask gates (`bid_vs_riv`,
87 +`estimate_vs_riv`, verdict). VAT or sales tax on the premium, import duties and shipping are **not**
88 +modelled and are stated as such.
89 +
72 90 ## 4. Scores
73 91
74 92 - **Liquidity (0–100)**: sales per month, active listings, number of sources, median days between
@@ -103,6 +121,22 @@ large discount on an illiquid or poorly identified asset cannot score high. Disc
103 121 from scratch on every valuation pass; a stale discount never survives a changed valuation. Period
104 122 changes beyond ±500 % are treated as data artefacts and are not published as movers.
105 123
124 +### Auction fees and all-in cost
125 +
126 +A hammer price is not what a buyer pays. Before any comparison with marketplace prices or with the
127 +RIV, RareIndex adds the house's buyer's premium (`data/fees/auction-houses.json`: marginal tiers,
128 +minimum/maximum, fixed fees, per-house confidence `published` / `approximate` / `none`; houses not on
129 +file fall back to a labelled default of 22 %). The basis is stored with every figure
130 +(`sales.fee_basis`, `auction_lots.fee_basis`): *premium included*, *premium added (published /
131 +≈ estimated / default)*, *no buyer premium* (fixed-price marketplaces, dealers, houses without a
132 +premium) or *fees unknown* (connector silent and house not on file — the price is kept as recorded
133 +and flagged). VAT/sales tax on the premium, import duties and shipping are excluded and stated as such.
134 +
135 +Live lots are assessed hourly on the same basis: current bid (or the low estimate, labelled "est.",
136 +when no bid has been placed) → USD at the current ECB rate → + premium → compared with the RIV of
137 +the lot's variant under the listing gates (transaction-based RIV, ≥ 5 sales, confidence ≥ 0.5,
138 +plausibility band, −50 % review threshold). Opening prices without a bid are never called deals.
139 +
106 140 ## 5. Indices
107 141
108 142 Each subindex (RARE-TCG, RARE-WATCH, …) is a **chain-linked** index of daily RIV changes across its
modified packages/database/src/schema/users.ts +1 −1
@@ -140,7 +140,7 @@ export const alerts = pgTable(
140 140 {
141 141 id: text('id').primaryKey(),
142 142 userId: text('user_id').notNull(),
143 − alertType: text('alert_type').notNull(), // price_below | price_above | new_listing | new_auction | auction_ending | record_sale | unusual_volume | market_move | rare_item | population_update
143 + alertType: text('alert_type').notNull(), // price_below | price_above | new_listing | new_auction | auction_ending | auction_below_riv | record_sale | unusual_volume | market_move | rare_item | population_update
144 144 targetType: text('target_type').notNull(),
145 145 targetId: text('target_id').notNull(),
146 146 threshold: money('threshold'),
added packages/valuation/src/auction.test.ts +62 −0
@@ -0,0 +1,62 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { assessLot } from './auction.js';
3 +import { feeBasisLabel } from './fees.js';
4 +
5 +const riv = { rivUsd: 10_000, confidence: 0.8, sampleSize: 30, basis: 'transactions' as const };
6 +
7 +describe('assessLot (§33–§35)', () => {
8 + it('uses the low estimate when there is no bid, on an all-in basis', () => {
9 + const a = assessLot({ house: "Sotheby's", currentBidUsd: null, bidCount: 0, estimateLowUsd: 6_000, estimateHighUsd: 9_000, riv });
10 + expect(a.basedOn).toBe('estimate');
11 + expect(a.allInBidUsd).toBeNull();
12 + expect(a.allInEstimateLowUsd).toBe(7_620); // 6,000 × 1.27
13 + expect(a.allInEstimateHighUsd).toBe(11_430);
14 + expect(a.estimateVsRiv).toBeCloseTo(-0.238, 3);
15 + expect(a.verdict).toBe('deal');
16 + expect(a.feeBasis).toBe('added_published');
17 + expect(a.buyerPremiumRate).toBe(0.27);
18 + });
19 + it('uses the all-in current bid when bids exist', () => {
20 + const a = assessLot({ house: 'Goldin', currentBidUsd: 7_000, bidCount: 3, estimateLowUsd: 5_000, estimateHighUsd: 8_000, riv });
21 + expect(a.basedOn).toBe('bid');
22 + expect(a.allInBidUsd).toBe(8_400); // 20 % premium
23 + expect(a.bidVsRiv).toBeCloseTo(-0.16, 3);
24 + expect(a.verdict).toBe('deal');
25 + });
26 + it('an opening bid with no bidders is ignored (it is not a price)', () => {
27 + const a = assessLot({ house: 'Goldin', currentBidUsd: 1, bidCount: 0, estimateLowUsd: null, estimateHighUsd: null, riv });
28 + expect(a.basedOn).toBe('none');
29 + expect(a.verdict).toBe('ungated');
30 + expect(a.bidVsRiv).toBeNull();
31 + });
32 + it('unknown house → default premium, labelled as such', () => {
33 + const a = assessLot({ house: 'Enchères du Coin', currentBidUsd: 8_000, bidCount: 2, estimateLowUsd: null, estimateHighUsd: null, riv });
34 + expect(a.feeBasis).toBe('added_default');
35 + expect(a.allInBidUsd).toBe(9_760); // 22 %
36 + expect(feeBasisLabel(a.feeBasis)).toContain('default');
37 + });
38 + it('variant mismatch → ungated, no discount written', () => {
39 + const a = assessLot({ house: 'Goldin', currentBidUsd: 5_000, bidCount: 4, estimateLowUsd: null, estimateHighUsd: null, riv, sameVariant: false });
40 + expect(a.verdict).toBe('ungated');
41 + expect(a.reasons).toContain('variant_mismatch');
42 + expect(a.bidVsRiv).toBeNull();
43 + expect(a.allInBidUsd).toBe(6_000); // the all-in cost is still informative
44 + });
45 + it('review and anomaly verdicts never produce a usable discount', () => {
46 + expect(assessLot({ house: 'Goldin', currentBidUsd: 2_000, bidCount: 5, estimateLowUsd: null, estimateHighUsd: null, riv }).verdict).toBe('review'); // 2,400 all-in vs 10,000
47 + const anomaly = assessLot({ house: 'Goldin', currentBidUsd: 200, bidCount: 5, estimateLowUsd: null, estimateHighUsd: null, riv });
48 + expect(anomaly.verdict).toBe('anomaly');
49 + expect(anomaly.bidVsRiv).toBeNull();
50 + });
51 + it('comps-only or thin valuations never qualify a lot', () => {
52 + expect(assessLot({ house: 'Goldin', currentBidUsd: 7_000, bidCount: 3, estimateLowUsd: null, estimateHighUsd: null, riv: { ...riv, basis: 'comps' } }).verdict).toBe('ungated');
53 + expect(assessLot({ house: 'Goldin', currentBidUsd: 7_000, bidCount: 3, estimateLowUsd: null, estimateHighUsd: null, riv: { ...riv, sampleSize: 3 } }).verdict).toBe('ungated');
54 + expect(assessLot({ house: 'Goldin', currentBidUsd: 7_000, bidCount: 3, estimateLowUsd: null, estimateHighUsd: null, riv: null }).reasons).toContain('no_riv');
55 + });
56 + it('fee basis labels are human-readable', () => {
57 + expect(feeBasisLabel('included')).toBe('premium included');
58 + expect(feeBasisLabel('none')).toBe('no buyer premium');
59 + expect(feeBasisLabel('unknown')).toBe('fees unknown');
60 + expect(feeBasisLabel('added_approximate')).toContain('estimated');
61 + });
62 +});
added packages/valuation/src/auction.ts +83 −0
@@ -0,0 +1,83 @@
1 +/**
2 + * Auction intelligence (§33–§35): a lot's current bid / estimate is compared with the RareIndex
3 + * Valuation only on a buyer-pays (all-in) basis — hammer + the house's buyer premium — and only
4 + * through the same gates as a marketplace ask (same variant, transaction-based RIV, ≥ 5 sales,
5 + * confidence ≥ 0.5, plausibility band, review threshold). Taxes, duties and shipping are out of
6 + * scope and are stated as such wherever an all-in figure is shown.
7 + */
8 +import { assessAsk, type AskVerdict } from './scores.js';
9 +import { allInPrice, type FeeBasis } from './fees.js';
10 +import { round } from '@rareindex/shared';
11 +
12 +export interface LotAssessmentInput {
13 + /** auction house name / connector id for the fee schedule */
14 + house: string | null | undefined;
15 + /** lot amounts already converted to USD (null when absent) */
16 + currentBidUsd: number | null;
17 + bidCount: number | null;
18 + estimateLowUsd: number | null;
19 + estimateHighUsd: number | null;
20 + hammerPriceUsd?: number | null;
21 + /** connector flag for the lot's quoted amounts; lots are hammer-basis unless the connector says otherwise */
22 + buyerPremiumIncluded?: boolean | null;
23 + /** rate converting USD into the schedule currency (for tier boundaries); 1 when unknown */
24 + fxUsdToScheduleCurrency?: number;
25 + /** valuation of the lot's own variant (or the asset when raw and representative) */
26 + riv: { rivUsd: number | null; confidence: number | null; sampleSize: number | null; basis?: 'transactions' | 'comps' | 'guide' | 'none' | null } | null;
27 + /** false when the lot's grade does not match the valuation's variant */
28 + sameVariant?: boolean;
29 +}
30 +
31 +export interface LotAssessment {
32 + buyerPremiumRate: number | null;
33 + feeBasis: FeeBasis;
34 + allInBidUsd: number | null;
35 + allInEstimateLowUsd: number | null;
36 + allInEstimateHighUsd: number | null;
37 + allInHammerUsd: number | null;
38 + rivUsd: number | null;
39 + /** (all-in bid − RIV) / RIV — null without a bid or when ungated/anomalous/review */
40 + bidVsRiv: number | null;
41 + /** (all-in low estimate − RIV) / RIV — null when ungated/anomalous/review */
42 + estimateVsRiv: number | null;
43 + /** verdict of the most informative comparison: the bid when there is one, else the low estimate */
44 + verdict: AskVerdict;
45 + reasons: string[];
46 + /** what the verdict is based on */
47 + basedOn: 'bid' | 'estimate' | 'none';
48 +}
49 +
50 +const allIn = (usd: number | null, i: LotAssessmentInput) => {
51 + if (usd === null || !(usd > 0)) return null;
52 + return allInPrice({ price: usd, buyerPremiumIncluded: i.buyerPremiumIncluded ?? false, house: i.house, saleType: 'auction', sourceType: 'auction_house', fxToScheduleCurrency: i.fxUsdToScheduleCurrency });
53 +};
54 +
55 +export function assessLot(i: LotAssessmentInput): LotAssessment {
56 + const hasBid = (i.bidCount ?? 0) > 0 && i.currentBidUsd !== null && i.currentBidUsd > 0;
57 + const bid = hasBid ? allIn(i.currentBidUsd, i) : null;
58 + const lo = allIn(i.estimateLowUsd, i);
59 + const hi = allIn(i.estimateHighUsd, i);
60 + const hammer = allIn(i.hammerPriceUsd ?? null, i);
61 + const ref = bid ?? lo ?? hi ?? hammer;
62 + const riv = i.riv?.rivUsd ?? null;
63 + const gate = (askUsd: number | null) =>
64 + assessAsk({ askUsd, rivUsd: riv, confidence: i.riv?.confidence ?? null, sampleSize: i.riv?.sampleSize ?? null, basis: i.riv?.basis ?? undefined, sameVariant: i.sameVariant });
65 + const bidA = bid ? gate(bid.allIn) : null;
66 + const loA = lo ? gate(lo.allIn) : null;
67 + const primary = bidA ?? loA;
68 + const usable = (a: ReturnType<typeof assessAsk> | null) => (a && (a.verdict === 'deal' || a.verdict === 'fair' || a.verdict === 'premium') ? a.discount : null);
69 + return {
70 + buyerPremiumRate: ref ? round(ref.rate, 4) : null,
71 + feeBasis: ref?.basis ?? 'unknown',
72 + allInBidUsd: bid?.allIn ?? null,
73 + allInEstimateLowUsd: lo?.allIn ?? null,
74 + allInEstimateHighUsd: hi?.allIn ?? null,
75 + allInHammerUsd: hammer?.allIn ?? null,
76 + rivUsd: riv,
77 + bidVsRiv: usable(bidA),
78 + estimateVsRiv: usable(loA),
79 + verdict: primary?.verdict ?? 'ungated',
80 + reasons: primary?.reasons ?? (riv === null ? ['no_riv'] : ['no_bid_or_estimate']),
81 + basedOn: bidA ? 'bid' : loA ? 'estimate' : 'none',
82 + };
83 +}
modified packages/valuation/src/index.ts +1 −0
@@ -6,3 +6,4 @@ export * from './depth.js';
6 6 export * from './liquidation.js';
7 7 export * from './verification.js';
8 8 export * from './fees.js';
9 +export * from './auction.js';
modified workers/account/alerts.ts +4 −0
@@ -47,6 +47,9 @@ async function assetState(db: Database, assetId: string, since: Date, now: Date)
47 47 const a = await db.select({ asset: assets, stats: assetStats }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, assetId)).limit(1);
48 48 const row = a[0];
49 49 if (!row) return null;
50 + const belowRiv = (await db.execute(sql`select l.id, l.title, l.ends_at, coalesce(au.auction_house, l.source_id) as house, l.all_in_bid_usd::float as bid, l.riv_usd_at_assessment::float as riv, l.bid_vs_riv::float as d, l.fee_basis
51 + from auction_lots l left join auctions au on au.id = l.auction_id
52 + where l.asset_id = ${assetId} and l.status in ('live','upcoming') and l.assessment_verdict = 'deal' and l.bid_vs_riv is not null and (l.ends_at is null or l.ends_at > now()) limit 5`)) as unknown as Array<{ id: string; title: string; ends_at: Date | null; house: string; bid: number; riv: number; d: number; fee_basis: string | null }>;
50 53 const [newListings, lots, ending, record, baseline, pop] = await Promise.all([
51 54 db.select({ id: listings.id, priceUsd: listings.priceUsd, sourceId: listings.sourceId, firstSeenAt: listings.firstSeenAt }).from(listings).where(and(eq(listings.assetId, assetId), eq(listings.availability, 'available'), gte(listings.firstSeenAt, since))),
52 55 db.select({ id: auctionLots.id, title: auctionLots.title, endsAt: auctionLots.endsAt, auctionHouse: sql<string>`coalesce(${auctionLots.sourceId}, '')` }).from(auctionLots).where(and(eq(auctionLots.assetId, assetId), gte(auctionLots.createdAt, since))),
@@ -79,6 +82,7 @@ async function assetState(db: Database, assetId: string, since: Date, now: Date)
79 82 newListings,
80 83 newAuctionLots: lots,
81 84 endingLots: ending.filter((l): l is typeof l & { endsAt: Date } => l.endsAt !== null),
85 + belowRivLots: belowRiv.map((l) => ({ id: l.id, title: l.title, endsAt: l.ends_at ? new Date(l.ends_at) : null, auctionHouse: l.house, allInBidUsd: Number(l.bid), rivUsd: Number(l.riv), bidVsRiv: Number(l.d), feeBasis: l.fee_basis })),
82 86 newRecordSale: newRecord,
83 87 populationChange,
84 88 };
modified workers/account/evaluate.ts +8 −0
@@ -32,6 +32,8 @@ export interface AssetState {
32 32 newListings: Array<{ id: string; priceUsd: number | null; sourceId: string; firstSeenAt: Date }>;
33 33 newAuctionLots: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string }>;
34 34 endingLots: Array<{ id: string; title: string; endsAt: Date; auctionHouse: string }>;
35 + /** live lots whose buyer-pays bid is ≥ 10 % below the variant RIV (assessment verdict 'deal') */
36 + belowRivLots?: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string; allInBidUsd: number; rivUsd: number; bidVsRiv: number; feeBasis: string | null }>;
35 37 newRecordSale: { priceUsd: number; saleDate: Date; sourceId: string } | null;
36 38 populationChange: { grader: string; from: number; to: number; date: string } | null;
37 39 }
@@ -98,6 +100,12 @@ export function evaluateAssetAlert(alert: AlertRow, s: AssetState, now = new Dat
98 100 const l = soon.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())[0]!;
99 101 return { kind: 'alert', title: `${title}: auction ends ${Math.max(1, Math.round((l.endsAt.getTime() - now.getTime()) / 3600_000))}h from now`, body: `${l.title} at ${l.auctionHouse}.`, href: `${href}?tab=listings`, facts: [['Ends', l.endsAt.toUTCString()]] };
100 102 }
103 + case 'auction_below_riv': {
104 + const lots = (s.belowRivLots ?? []).filter((l) => !l.endsAt || l.endsAt.getTime() > now.getTime());
105 + if (!lots.length) return null;
106 + const l = lots.sort((a, b) => a.bidVsRiv - b.bidVsRiv)[0]!;
107 + return { kind: 'alert', title: `${title}: auction bid ${pct(l.bidVsRiv)} vs RIV (all-in)`, body: `${l.title} at ${l.auctionHouse}: current bid + buyer premium ≈ ${usd(l.allInBidUsd)} against a RIV of ${usd(l.rivUsd)}${l.feeBasis?.startsWith('added_') ? ' (premium estimated; taxes and shipping excluded)' : ''}. Analytical data, not advice.`, href: `${href}?tab=listings`, facts: [['All-in bid', usd(l.allInBidUsd)], ['RIV', usd(l.rivUsd)], ['Gap', pct(l.bidVsRiv)], ...(l.endsAt ? [['Ends', l.endsAt.toUTCString()] as [string, string]] : [])] };
108 + }
101 109 case 'record_sale':
102 110 if (!s.newRecordSale) return null;
103 111 return { kind: 'alert', title: `${title}: new record sale ${usd(s.newRecordSale.priceUsd)}`, body: `Highest verified sale to date, on ${s.newRecordSale.sourceId} (${s.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `${href}?tab=sales`, facts: [['Record', usd(s.newRecordSale.priceUsd)], ...(s.athUsd ? [['Previous high', usd(s.athUsd)] as [string, string]] : [])] };
added workers/auctions/assess.ts +124 −0
@@ -0,0 +1,124 @@
1 +/**
2 + * Auction lot assessment job (§33–§35): converts estimates, current bids and hammer prices to USD,
3 + * adds the house's buyer premium (lots are hammer-basis) and compares the buyer-pays figure with the
4 + * RIV of the lot's own variant through the same gates as marketplace asks. Writes the result on the
5 + * lot (all_in_*, bid_vs_riv, estimate_vs_riv, assessment_verdict). Never writes an ungated /
6 + * anomalous / review discount into bid_vs_riv.
7 + */
8 +import { sql } from 'drizzle-orm';
9 +import { logger } from '@rareindex/shared';
10 +import { assessLot, feeScheduleFor } from '@rareindex/valuation';
11 +import { db } from '../lib/db.ts';
12 +import { usdRateFor } from '../lib/fx.ts';
13 +
14 +const log = logger.child({ component: 'auctions-assess' });
15 +
16 +interface LotRow {
17 + id: string;
18 + asset_id: string | null;
19 + variant_id: string | null;
20 + grader: string | null;
21 + grade: string | null;
22 + currency: string | null;
23 + estimate_low: number | null;
24 + estimate_high: number | null;
25 + current_bid: number | null;
26 + hammer_price: number | null;
27 + bid_count: number | null;
28 + status: string;
29 + auction_house: string | null;
30 + source_id: string;
31 + // valuation of the lot's own variant (variant_stats) when the variant is known
32 + v_riv: number | null;
33 + v_conf: number | null;
34 + v_n: number | null;
35 + v_method: string | null;
36 + // asset-level fallback
37 + a_riv: number | null;
38 + a_conf: number | null;
39 + a_n: number | null;
40 + a_method: string | null;
41 + rep_grader: string | null;
42 + rep_grade: string | null;
43 +}
44 +
45 +export interface AssessResult {
46 + scanned: number;
47 + assessed: number;
48 + verdicts: Record<string, number>;
49 + fxMissing: number;
50 +}
51 +
52 +const basisOf = (method: string | null): 'transactions' | 'comps' | 'guide' | 'none' | null => {
53 + const m = method?.split(':')[1];
54 + return m === 'transactions' || m === 'comps' || m === 'guide' || m === 'none' ? m : null;
55 +};
56 +
57 +export async function assessLots(opts: { limit?: number; all?: boolean; now?: Date } = {}): Promise<AssessResult> {
58 + const now = opts.now ?? new Date();
59 + const limit = opts.limit ?? 20_000;
60 + const res: AssessResult = { scanned: 0, assessed: 0, verdicts: {}, fxMissing: 0 };
61 + const rows = (await db().execute(sql`
62 + select l.id, l.asset_id, l.variant_id, l.grader, l.grade, l.currency, l.estimate_low::float as estimate_low, l.estimate_high::float as estimate_high,
63 + l.current_bid::float as current_bid, l.hammer_price::float as hammer_price, l.bid_count, l.status, au.auction_house, l.source_id,
64 + vs.riv_usd::float as v_riv, vs.riv_confidence::float as v_conf, vs.riv_sample_size as v_n,
65 + (select v.method from valuations v where v.variant_id = l.variant_id order by v.computed_at desc limit 1) as v_method,
66 + st.riv_usd::float as a_riv, st.riv_confidence::float as a_conf, st.riv_sample_size as a_n,
67 + (select v.method from valuations v where v.variant_id = st.riv_variant_id order by v.computed_at desc limit 1) as a_method,
68 + rv.grader as rep_grader, rv.grade as rep_grade
69 + from auction_lots l
70 + join auctions au on au.id = l.auction_id
71 + left join variant_stats vs on vs.variant_id = l.variant_id
72 + left join asset_stats st on st.asset_id = l.asset_id
73 + left join asset_variants rv on rv.id = st.riv_variant_id
74 + where l.asset_id is not null
75 + and ((l.status in ('live','upcoming') and (l.ends_at is null or l.ends_at > now() - interval '1 day')) or (l.status = 'ended' and l.hammer_price is not null))
76 + ${opts.all ? sql`` : sql`and (l.assessed_at is null or l.assessed_at < l.updated_at or l.assessed_at < now() - interval '6 hours')`}
77 + order by l.ends_at asc nulls last
78 + limit ${limit}`)) as unknown as LotRow[];
79 + res.scanned = rows.length;
80 + for (const r of rows) {
81 + const currency = r.currency ?? 'USD';
82 + const fx = await usdRateFor(currency, now);
83 + if (!fx || fx.rate <= 0) {
84 + res.fxMissing++;
85 + continue;
86 + }
87 + const toUsd = (v: number | null) => (v === null || v === undefined ? null : v / fx.rate);
88 + const schedule = feeScheduleFor(r.auction_house) ?? feeScheduleFor(r.source_id);
89 + let fxUsdToScheduleCurrency = 1;
90 + if (schedule && schedule.currency !== 'USD') {
91 + const sr = await usdRateFor(schedule.currency, now);
92 + if (sr && sr.rate > 0) fxUsdToScheduleCurrency = sr.rate;
93 + }
94 + // Which valuation is comparable: the lot's own variant; else the asset headline only when the
95 + // lot is raw (no grader) and the representative variant is raw too, or grades match.
96 + let riv: Parameters<typeof assessLot>[0]['riv'] = null;
97 + let sameVariant: boolean | undefined = undefined;
98 + if (r.variant_id && r.v_riv !== null) riv = { rivUsd: r.v_riv, confidence: r.v_conf, sampleSize: r.v_n, basis: basisOf(r.v_method) ?? (Number(r.v_n ?? 0) >= 5 ? 'transactions' : null) };
99 + else if (r.a_riv !== null) {
100 + const lotRaw = !r.grader || r.grader === 'raw';
101 + const repRaw = !r.rep_grader;
102 + sameVariant = (lotRaw && repRaw) || (!lotRaw && r.grader === r.rep_grader && (r.grade ?? null) === (r.rep_grade ?? null));
103 + riv = { rivUsd: r.a_riv, confidence: r.a_conf, sampleSize: r.a_n, basis: basisOf(r.a_method) ?? (Number(r.a_n ?? 0) >= 5 ? 'transactions' : null) };
104 + }
105 + const estLowUsd = toUsd(r.estimate_low);
106 + const estHighUsd = toUsd(r.estimate_high);
107 + const bidUsd = toUsd(r.current_bid);
108 + const hammerUsd = toUsd(r.hammer_price);
109 + const a = assessLot({ house: schedule?.id ?? r.auction_house ?? r.source_id, currentBidUsd: bidUsd, bidCount: r.bid_count, estimateLowUsd: estLowUsd, estimateHighUsd: estHighUsd, hammerPriceUsd: hammerUsd, buyerPremiumIncluded: false, fxUsdToScheduleCurrency, riv, sameVariant });
110 + await db().execute(sql`
111 + update auction_lots set
112 + estimate_low_usd = ${estLowUsd}, estimate_high_usd = ${estHighUsd}, current_bid_usd = ${bidUsd}, hammer_price_usd = ${hammerUsd},
113 + fx_rate = ${fx.rate}, fx_date = ${fx.date}::date,
114 + buyer_premium_rate = ${a.buyerPremiumRate}, fee_basis = ${a.feeBasis},
115 + all_in_bid_usd = ${a.allInBidUsd}, all_in_estimate_low_usd = ${a.allInEstimateLowUsd}, all_in_estimate_high_usd = ${a.allInEstimateHighUsd},
116 + riv_usd_at_assessment = ${a.rivUsd}, bid_vs_riv = ${a.bidVsRiv}, estimate_vs_riv = ${a.estimateVsRiv},
117 + assessment_verdict = ${a.verdict}, assessed_at = ${now}
118 + where id = ${r.id}`);
119 + res.assessed++;
120 + res.verdicts[a.verdict] = (res.verdicts[a.verdict] ?? 0) + 1;
121 + }
122 + log.info(res, 'auction lots assessed');
123 + return res;
124 +}
modified workers/cli.ts +22 −1
@@ -6,6 +6,7 @@
6 6 * ri certs [--limit N] [--grader psa] | ri certs --backfill (verify cert numbers via cert_lookup connectors / register certs from existing sales+listings)
7 7 * ri normalize [--connector id] [--limit N] | ri resolve [--limit N]
8 8 * ri value [--asset id | --all] [--history] | ri premiums [--category slug] | ri regrade [--kind sale|listing|both] [--dry-run]
9 + * ri fees --backfill [--limit N] [--connector id] [--force] | ri auctions --assess [--all] [--limit N]
9 10 * ri index | ri snapshots | ri radar | ri health [--probe] | ri fx [--backfill] | ri benchmarks
10 11 * ri expire | ri stats | ri run-all [--limit N] | ri worker
11 12 */
@@ -22,6 +23,8 @@ import { activeBackfill, pauseBackfill, runCrawl, startBackfill } from './crawle
22 23 import { normalizeBatch } from './normalizer/index.ts';
23 24 import { pendingCount, resolveBatch } from './entity-resolution/index.ts';
24 25 import { regradeRecords } from './entity-resolution/regrade.ts';
26 +import { backfillFees } from './fees-backfill.ts';
27 +import { assessLots } from './auctions/assess.ts';
25 28 import { assetsNeedingValuation, computePremiums, valueAsset, valueMany } from './valuation/run.ts';
26 29 import { runCategorySnapshots, runIndices, runRadar } from './indices/run.ts';
27 30 import { syncFx } from './fx.ts';
@@ -130,6 +133,24 @@ async function main() {
130 133 print(await regradeRecords({ kind, connectorId: str('connector'), limit: num('limit', 50_000), dryRun: Boolean(flags['dry-run']), recheck: Boolean(flags.recheck) }));
131 134 break;
132 135 }
136 + case 'fees': {
137 + // ri fees --backfill [--limit N] [--connector id] [--force] (buyer-pays price on existing sales, §35)
138 + if (!flags.backfill) {
139 + print('usage: ri fees --backfill [--limit N] [--connector id] [--force]');
140 + break;
141 + }
142 + print(await backfillFees({ limit: num('limit'), connectorId: str('connector'), force: Boolean(flags.force) }));
143 + break;
144 + }
145 + case 'auctions': {
146 + // ri auctions --assess [--all] [--limit N] (all-in bid / estimate vs RIV on live lots, §33–§35)
147 + if (!flags.assess) {
148 + print('usage: ri auctions --assess [--all] [--limit N]');
149 + break;
150 + }
151 + print(await assessLots({ limit: num('limit', 50_000), all: Boolean(flags.all) }));
152 + break;
153 + }
133 154 case 'value': {
134 155 const asset = str('asset');
135 156 if (asset) print(await valueAsset(asset, { rebuildHistory: Boolean(flags.history) }));
@@ -203,7 +224,7 @@ async function main() {
203 224 return; // keep running
204 225 }
205 226 default:
206 − print(`RareIndex CLI\n ri connectors | crawl <id> [--mode probe|incremental|backfill] [--limit N] | normalize [--connector id] | resolve\n ri value [--asset id|--all] [--history] | premiums | regrade [--dry-run] | index | snapshots | radar | health [--probe] | fx [--backfill] | benchmarks | expire\n ri stats | run-all [--limit N] [--history] | worker`);
227 + print(`RareIndex CLI\n ri connectors | crawl <id> [--mode probe|incremental|backfill] [--limit N] | normalize [--connector id] | resolve\n ri value [--asset id|--all] [--history] | premiums | regrade [--dry-run] | fees --backfill | auctions --assess | index | snapshots | radar | health [--probe] | fx [--backfill] | benchmarks | expire\n ri stats | run-all [--limit N] [--history] | worker`);
207 228 }
208 229 await flushCosts();
209 230 await closeDb();
modified workers/entity-resolution/writers.ts +35 −1
@@ -1,10 +1,11 @@
1 1 import { and, eq, sql } from 'drizzle-orm';
2 2 import { auctionLots, auctions, images, listingEvents, listings, news, populationReports, priceObservations, sales, sources } from '@rareindex/database';
3 3 import { normalizeCondition, parseGradeFromTitle } from '@rareindex/taxonomy';
4 +import { allInPrice, feeScheduleFor } from '@rareindex/valuation';
4 5 import type { Grade } from '@rareindex/shared';
5 6 import { newId, sha256, toDateOnly, type NormalizedAuctionLot, type NormalizedCatalogItem, type NormalizedListing, type NormalizedNewsItem, type NormalizedPopulationReport, type NormalizedPriceObservation, type NormalizedRecord, type NormalizedSale } from '@rareindex/shared';
6 7 import { db } from '../lib/db.ts';
7 −import { toUsd } from '../lib/fx.ts';
8 +import { toUsd, usdRateFor } from '../lib/fx.ts';
8 9 import { audit } from '../lib/audit.ts';
9 10 import { enrichAttributesFromTitle, ensureVariant, resolveAsset, type Resolution } from './resolver.ts';
10 11 import { recordCertificate } from './certificates.ts';
@@ -37,6 +38,35 @@ export interface ApplyResult {
37 38 event?: 'sale_detected' | 'listing_created' | 'listing_updated' | 'entity_created';
38 39 }
39 40
41 +const sourceMetaCache = new Map<string, { name: string; sourceType: string }>();
42 +async function sourceMeta(sourceId: string): Promise<{ name: string; sourceType: string } | null> {
43 + const hit = sourceMetaCache.get(sourceId);
44 + if (hit) return hit;
45 + const [row] = await db().select({ name: sources.name, sourceType: sources.sourceType }).from(sources).where(eq(sources.id, sourceId)).limit(1);
46 + if (!row) return null;
47 + sourceMetaCache.set(sourceId, row);
48 + return row;
49 +}
50 +
51 +/**
52 + * Buyer-pays price of a sale (§35): price_usd + the house's buyer premium when the record is
53 + * hammer-only. `fxToScheduleCurrency` converts the native amount into the schedule's currency so
54 + * marginal tiers apply at the right thresholds. Returns nulls when the basis cannot be established.
55 + */
56 +export async function saleAllIn(rec: { priceUsd: number; currency: string; buyerPremiumIncluded: boolean | null | undefined; auctionHouse: string | null | undefined; saleType: string | null | undefined; sourceId: string; connectorId: string; saleDate: Date }): Promise<{ allInUsd: number; feeBasis: string; buyerPremiumRate: number }> {
57 + const meta = await sourceMeta(rec.sourceId);
58 + const house = rec.auctionHouse ?? meta?.name ?? rec.connectorId;
59 + const schedule = feeScheduleFor(house) ?? feeScheduleFor(rec.connectorId);
60 + let fxToScheduleCurrency = 1;
61 + if (schedule && schedule.currency !== 'USD') {
62 + // price is already USD here; tiers are in the schedule currency → USD × (schedule units per USD)
63 + const r = await usdRateFor(schedule.currency, rec.saleDate);
64 + if (r && r.rate > 0) fxToScheduleCurrency = r.rate;
65 + }
66 + const a = allInPrice({ price: rec.priceUsd, buyerPremiumIncluded: rec.buyerPremiumIncluded, house: schedule ? schedule.id : house, saleType: rec.saleType, sourceType: meta?.sourceType ?? null, fxToScheduleCurrency });
67 + return { allInUsd: a.allIn, feeBasis: a.basis, buyerPremiumRate: a.rate };
68 +}
69 +
40 70 const trustCache = new Map<string, number>();
41 71 async function sourceTrust(sourceId: string): Promise<number> {
42 72 const hit = trustCache.get(sourceId);
@@ -118,6 +148,7 @@ async function applySale(rec: NormalizedSale): Promise<ApplyResult> {
118 148 if (!fx) throw new FxMissingError(rec.currency, rec.saleDate);
119 149 const dedupeKey = sha256(['sale', rec.sourceId, rec.externalId ?? rec.sourceUrl, toDateOnly(rec.saleDate), rec.price.toFixed(2)].join('|'));
120 150 const trust = await sourceTrust(rec.sourceId);
151 + const fees = await saleAllIn({ priceUsd: fx.usd, currency: rec.currency, buyerPremiumIncluded: rec.buyerPremiumIncluded, auctionHouse: rec.auctionHouse, saleType: rec.saleType, sourceId: rec.sourceId, connectorId: rec.connectorId, saleDate: rec.saleDate });
121 152 const confidence = Math.min(rec.confidence, r.confidence);
122 153 const flags: string[] = [];
123 154 if (rec.isBundle || rec.quantity > 1) flags.push('bundle');
@@ -143,6 +174,9 @@ async function applySale(rec: NormalizedSale): Promise<ApplyResult> {
143 174 fxRate: fx.rate,
144 175 fxDate: fx.fxDate,
145 176 buyerPremiumIncluded: rec.buyerPremiumIncluded,
177 + allInUsd: fees.allInUsd,
178 + feeBasis: fees.feeBasis,
179 + buyerPremiumRate: fees.buyerPremiumRate,
146 180 quantity: rec.quantity,
147 181 isBundle: rec.isBundle,
148 182 condition,
added workers/fees-backfill.ts +71 −0
@@ -0,0 +1,71 @@
1 +/**
2 + * Backfill of the buyer-pays price on existing sales (§35): recomputes all_in_usd / fee_basis /
3 + * buyer_premium_rate from the connector flag, the auction house and the fee schedule. Idempotent
4 + * and resumable: rows that already carry a fee_basis are skipped unless `force`. Touched assets are
5 + * marked for revaluation (asset_stats.updated_at pushed back, like `ri regrade`).
6 + */
7 +import { sql } from 'drizzle-orm';
8 +import { logger } from '@rareindex/shared';
9 +import { db } from './lib/db.ts';
10 +import { saleAllIn } from './entity-resolution/writers.ts';
11 +
12 +const log = logger.child({ component: 'fees-backfill' });
13 +
14 +export interface FeesBackfillResult {
15 + scanned: number;
16 + updated: number;
17 + byBasis: Record<string, number>;
18 + assets: number;
19 +}
20 +
21 +interface Row {
22 + id: string;
23 + asset_id: string;
24 + price_usd: number;
25 + currency: string;
26 + buyer_premium_included: boolean | null;
27 + auction_house: string | null;
28 + sale_type: string;
29 + source_id: string;
30 + connector_id: string;
31 + sale_date: Date | string;
32 + fee_basis: string | null;
33 +}
34 +
35 +export async function backfillFees(opts: { limit?: number; connectorId?: string; force?: boolean; batch?: number } = {}): Promise<FeesBackfillResult> {
36 + const limit = opts.limit ?? 5_000_000;
37 + const batch = opts.batch ?? 5000;
38 + const res: FeesBackfillResult = { scanned: 0, updated: 0, byBasis: {}, assets: 0 };
39 + const touched = new Set<string>();
40 + let lastId = '';
41 + while (res.scanned < limit) {
42 + const rows = (await db().execute(sql`
43 + select s.id, s.asset_id, s.price_usd::float as price_usd, s.currency, s.buyer_premium_included, s.auction_house, s.sale_type, s.source_id, s.connector_id, s.sale_date, s.fee_basis
44 + from sales s
45 + where s.id > ${lastId}
46 + ${opts.connectorId ? sql`and s.connector_id = ${opts.connectorId}` : sql``}
47 + ${opts.force ? sql`` : sql`and s.fee_basis is null`}
48 + order by s.id limit ${Math.min(batch, limit - res.scanned)}`)) as unknown as Row[];
49 + if (!rows.length) break;
50 + res.scanned += rows.length;
51 + lastId = rows[rows.length - 1]!.id;
52 + const updates: Array<{ id: string; allIn: number; basis: string; rate: number }> = [];
53 + for (const r of rows) {
54 + const f = await saleAllIn({ priceUsd: Number(r.price_usd), currency: r.currency, buyerPremiumIncluded: r.buyer_premium_included, auctionHouse: r.auction_house, saleType: r.sale_type, sourceId: r.source_id, connectorId: r.connector_id, saleDate: new Date(r.sale_date) });
55 + res.byBasis[f.feeBasis] = (res.byBasis[f.feeBasis] ?? 0) + 1;
56 + updates.push({ id: r.id, allIn: f.allInUsd, basis: f.feeBasis, rate: f.buyerPremiumRate });
57 + if (f.feeBasis.startsWith('added_')) touched.add(r.asset_id);
58 + }
59 + // one statement per batch; the payload travels as JSON (drizzle serialises JS arrays as records, not SQL arrays)
60 + await db().execute(sql`
61 + update sales s set all_in_usd = u.all_in, fee_basis = u.basis, buyer_premium_rate = u.rate
62 + from json_to_recordset(${JSON.stringify(updates.map((u) => ({ id: u.id, all_in: u.allIn, basis: u.basis, rate: u.rate })))}::json) as u(id text, all_in numeric, basis text, rate real)
63 + where s.id = u.id`);
64 + res.updated += updates.length;
65 + log.info({ scanned: res.scanned, updated: res.updated, lastId }, 'fees backfill progress');
66 + }
67 + res.assets = touched.size;
68 + const ids = [...touched];
69 + for (let i = 0; i < ids.length; i += 1000) await db().execute(sql`update asset_stats set updated_at = '1970-01-01' where asset_id in ${ids.slice(i, i + 1000)}`);
70 + return res;
71 +}
modified workers/indices/run.ts +17 −2
@@ -62,8 +62,8 @@ async function dailySaleStats(variantIds: string[], dates: string[]): Promise<Ma
62 62 const m = new Map<string, DayStats>();
63 63 if (!variantIds.length || !dates.length) return m;
64 64 const rows = await db().execute(sql`
65 − select to_char(sale_date at time zone 'UTC', 'YYYY-MM-DD') as d, count(*)::int as n, sum(price_usd)::float as vol,
66 − percentile_cont(0.5) within group (order by price_usd)::float as med, avg(price_usd)::float as avg
65 + select to_char(sale_date at time zone 'UTC', 'YYYY-MM-DD') as d, count(*)::int as n, sum(coalesce(all_in_usd, price_usd))::float as vol,
66 + percentile_cont(0.5) within group (order by coalesce(all_in_usd, price_usd))::float as med, avg(coalesce(all_in_usd, price_usd))::float as avg
67 67 from sales where status = 'valid' and variant_id in ${variantIds} and sale_date >= ${dates[0]}::date
68 68 group by 1`);
69 69 for (const r of rows as unknown as Array<{ d: string; n: number; vol: number; med: number; avg: number }>) m.set(r.d, { transactions: r.n, volume: r.vol, medianSale: r.med, avgSale: r.avg });
@@ -267,6 +267,21 @@ export async function runRadar(opts: { now?: Date } = {}): Promise<number> {
267 267 await db().insert(radarFindings).values({ id: newId('event'), assetId: r.asset_id, kind: 'price_discrepancy', score: -r.d * r.conf, evidence: { askUsd: r.ask, rivUsd: r.riv, discount: r.d, confidence: r.conf }, entityType: 'listing', entityId: r.id, detectedAt: now, expiresAt: new Date(now.getTime() + 7 * DAY) }).onConflictDoUpdate({ target: [radarFindings.kind, radarFindings.entityType, radarFindings.entityId], set: { detectedAt: now, score: -r.d * r.conf, evidence: { askUsd: r.ask, rivUsd: r.riv, discount: r.d, confidence: r.conf } } });
268 268 n++;
269 269 }
270 + // auction lots whose buyer-pays bid is ≥ 10 % below the variant RIV (§33–§35), ending within 7 days.
271 + // Neutral wording (§41–§42): a gap between a bid and a valuation, never a statement about the seller.
272 + const lots = await db().execute(sql`
273 + select l.id, l.asset_id, l.bid_vs_riv::float as d, l.all_in_bid_usd::float as bid, l.riv_usd_at_assessment::float as riv, l.fee_basis, l.ends_at, l.url, au.auction_house,
274 + coalesce(vs.riv_confidence, st.riv_confidence)::float as conf
275 + from auction_lots l join auctions au on au.id = l.auction_id
276 + left join variant_stats vs on vs.variant_id = l.variant_id left join asset_stats st on st.asset_id = l.asset_id
277 + where l.status in ('live','upcoming') and l.assessment_verdict = 'deal' and l.bid_vs_riv is not null and l.all_in_bid_usd > 0
278 + and l.ends_at between now() and now() + interval '7 days' and coalesce(vs.riv_confidence, st.riv_confidence) >= 0.6
279 + limit 2000`);
280 + for (const r of lots as unknown as Array<{ id: string; asset_id: string; d: number; bid: number; riv: number; fee_basis: string | null; ends_at: Date; url: string; auction_house: string; conf: number }>) {
281 + const evidence = { allInBidUsd: r.bid, rivUsd: r.riv, bidVsRiv: r.d, feeBasis: r.fee_basis, endsAt: r.ends_at, house: r.auction_house, url: r.url };
282 + await db().insert(radarFindings).values({ id: newId('event'), assetId: r.asset_id, kind: 'auction_below_riv', score: -r.d * r.conf, evidence, entityType: 'auction_lot', entityId: r.id, detectedAt: now, expiresAt: new Date(r.ends_at) }).onConflictDoUpdate({ target: [radarFindings.kind, radarFindings.entityType, radarFindings.entityId], set: { detectedAt: now, score: -r.d * r.conf, evidence, expiresAt: new Date(r.ends_at) } });
283 + n++;
284 + }
270 285 // first listing in years: asset with a new listing (first_seen last 7d) and no listing/sale in the prior 2 years
271 286 const firsts = await db().execute(sql`
272 287 select l.id, l.asset_id from listings l
modified workers/lib/queue.ts +1 −0
@@ -22,6 +22,7 @@ export const JOBS = {
22 22 accountJobs: 'account.jobs',
23 23 imagesProcess: 'images.process',
24 24 certsVerify: 'certs.verify',
25 + auctionsAssess: 'auctions.assess',
25 26 } as const;
26 27 export type JobName = (typeof JOBS)[keyof typeof JOBS];
27 28
modified workers/main.ts +5 −0
@@ -19,6 +19,7 @@ import { computeHealth } from './health.ts';
19 19 import { expireListings } from './listings-expire.ts';
20 20 import { processImages } from './image-processing/index.ts';
21 21 import { verifyCertificates } from './certs-verify.ts';
22 +import { assessLots } from './auctions/assess.ts';
22 23
23 24 /**
24 25 * RareIndex worker process (PM2: rareindex-worker). One process runs the queue handlers and the
@@ -110,6 +111,9 @@ export async function startWorker(): Promise<() => Promise<void>> {
110 111 await queue.work<{ limit?: number; grader?: string }>(JOBS.certsVerify, { concurrency: 1, pollingIntervalSeconds: 60 }, async (data) => {
111 112 await verifyCertificates({ limit: data.limit ?? 100, grader: data.grader });
112 113 });
114 + await queue.work<{ limit?: number; all?: boolean }>(JOBS.auctionsAssess, { concurrency: 1, pollingIntervalSeconds: 60 }, async (data) => {
115 + await assessLots({ limit: data.limit ?? 20_000, all: data.all ?? false });
116 + });
113 117 await queue.work(JOBS.accountJobs, { concurrency: 1, pollingIntervalSeconds: 60 }, async () => {
114 118 try {
115 119 const accountModule = './account/index.ts'; // provided by the account module when present
@@ -133,6 +137,7 @@ export async function startWorker(): Promise<() => Promise<void>> {
133 137 await queue.schedule(JOBS.imagesProcess, '50 5 * * *', { limit: 20000, recheck: true });
134 138 await queue.schedule(JOBS.imagesProcess, '*/15 * * * *', { limit: 3000 });
135 139 await queue.schedule(JOBS.accountJobs, '*/10 * * * *', {});
140 + await queue.schedule(JOBS.auctionsAssess, '*/20 * * * *', { limit: 20000 }); // §33–§35: all-in bid vs RIV on live lots
136 141
137 142 // ---- crawl scheduler loop ----
138 143 const tick = async () => {
modified workers/valuation/run.ts +14 −4
@@ -32,7 +32,10 @@ interface SaleRow {
32 32 variantId: string | null;
33 33 sourceId: string;
34 34 saleDate: Date;
35 + /** buyer-pays price: coalesce(all_in_usd, price_usd) — hammer + estimated premium when applicable (§35) */
35 36 priceUsd: number;
37 + /** included | added_published | added_approximate | added_default | none | unknown | null */
38 + feeBasis: string | null;
36 39 grader: string | null;
37 40 grade: string | null;
38 41 quantity: number;
@@ -41,6 +44,7 @@ interface SaleRow {
41 44 confidence: number;
42 45 flags: string[];
43 46 }
47 +const BUYER_PAYS = sql<number>`coalesce(${sales.allInUsd}, ${sales.priceUsd})`;
44 48
45 49 /** Value one asset: outliers → per-variant valuations → asset stats, snapshots, listing discounts. */
46 50 export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHistory?: boolean } = {}): Promise<{ variants: number; riv: number | null }> {
@@ -51,11 +55,11 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis
51 55 const variants = await db().select().from(assetVariants).where(eq(assetVariants.assetId, assetId));
52 56 const since3y = new Date(now.getTime() - 3 * 365 * DAY);
53 57 const saleRows = (await db()
54 − .select({ id: sales.id, variantId: sales.variantId, sourceId: sales.sourceId, saleDate: sales.saleDate, priceUsd: sales.priceUsd, grader: sales.grader, grade: sales.grade, quantity: sales.quantity, isBundle: sales.isBundle, status: sales.status, confidence: sales.confidence, flags: sales.flags })
58 + .select({ id: sales.id, variantId: sales.variantId, sourceId: sales.sourceId, saleDate: sales.saleDate, priceUsd: BUYER_PAYS, feeBasis: sales.feeBasis, grader: sales.grader, grade: sales.grade, quantity: sales.quantity, isBundle: sales.isBundle, status: sales.status, confidence: sales.confidence, flags: sales.flags })
55 59 .from(sales)
56 60 .where(and(eq(sales.assetId, assetId), gte(sales.saleDate, since3y)))
57 61 .orderBy(desc(sales.saleDate))) as SaleRow[];
58 − const allSalesCount = (await db().select({ n: sql<number>`count(*)::int`, min: sql<number>`min(${sales.priceUsd})`, max: sql<number>`max(${sales.priceUsd})`, minAt: sql<Date>`(array_agg(${sales.saleDate} order by ${sales.priceUsd} asc))[1]`, maxAt: sql<Date>`(array_agg(${sales.saleDate} order by ${sales.priceUsd} desc))[1]` }).from(sales).where(and(eq(sales.assetId, assetId), eq(sales.status, 'valid'))))[0]!;
62 + const allSalesCount = (await db().select({ n: sql<number>`count(*)::int`, min: sql<number>`min(${BUYER_PAYS})`, max: sql<number>`max(${BUYER_PAYS})`, minAt: sql<Date>`(array_agg(${sales.saleDate} order by ${BUYER_PAYS} asc))[1]`, maxAt: sql<Date>`(array_agg(${sales.saleDate} order by ${BUYER_PAYS} desc))[1]` }).from(sales).where(and(eq(sales.assetId, assetId), eq(sales.status, 'valid'))))[0]!;
59 63
60 64 // 1. outliers per variant (flag only new ones; never delete)
61 65 const flagsToApply: Array<{ id: string; reason: string; score: number }> = [];
@@ -112,6 +116,12 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis
112 116 const salesCount = own.filter((s) => (s.status ?? 'valid') === 'valid').length;
113 117 const sales30d = own.filter((s) => (s.status ?? 'valid') === 'valid' && now.getTime() - s.date.getTime() <= 30 * DAY).length;
114 118 const activeL = await activeListingStats(assetId, v.id);
119 + // §35: say when the inputs include an estimated (not invoiced) buyer premium
120 + if (out.salesUsed.length) {
121 + const used = new Set(out.salesUsed);
122 + const est = (byVariant.get(v.id) ?? []).filter((s) => used.has(s.id) && s.feeBasis?.startsWith('added_')).length;
123 + if (est) out.notes.push(`${Math.round((100 * est) / out.salesUsed.length)} % of inputs include an estimated buyer premium`);
124 + }
115 125 results.push({ variantId: v.id, isDefault: v.isDefault, out, salesCount, sales30d, activeL });
116 126 if (out.riv === null && salesCount === 0 && (obsByVariant.get(v.id) ?? []).length === 0) continue;
117 127 await db().insert(valuations).values({
@@ -189,7 +199,7 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis
189 199 // ATH / ATL / drawdown belong to the same series as the headline RIV: the representative variant.
190 200 // (An asset-wide ATH mixed a sealed copy's record with a loose copy's valuation → −99 % "drawdowns".)
191 201 const extremes = rep
192 − ? (await db().select({ n: sql<number>`count(*)::int`, min: sql<number>`min(${sales.priceUsd})`, max: sql<number>`max(${sales.priceUsd})`, minAt: sql<Date>`(array_agg(${sales.saleDate} order by ${sales.priceUsd} asc))[1]`, maxAt: sql<Date>`(array_agg(${sales.saleDate} order by ${sales.priceUsd} desc))[1]` }).from(sales).where(and(eq(sales.assetId, assetId), eq(sales.variantId, rep.variantId), eq(sales.status, 'valid'), sql`${sales.quantity} = 1`, sql`not ${sales.isBundle}`)))[0]!
202 + ? (await db().select({ n: sql<number>`count(*)::int`, min: sql<number>`min(${BUYER_PAYS})`, max: sql<number>`max(${BUYER_PAYS})`, minAt: sql<Date>`(array_agg(${sales.saleDate} order by ${BUYER_PAYS} asc))[1]`, maxAt: sql<Date>`(array_agg(${sales.saleDate} order by ${BUYER_PAYS} desc))[1]` }).from(sales).where(and(eq(sales.assetId, assetId), eq(sales.variantId, rep.variantId), eq(sales.status, 'valid'), sql`${sales.quantity} = 1`, sql`not ${sales.isBundle}`)))[0]!
193 203 : allSalesCount;
194 204 const salesCount = allSalesCount.n;
195 205 const sales30d = validSales.filter((s) => now.getTime() - s.saleDate.getTime() <= 30 * DAY).length;
@@ -375,7 +385,7 @@ export async function computePremiums(categorySlug?: string): Promise<number> {
375 385 let written = 0;
376 386 for (const cat of cats) {
377 387 const rows = await db()
378 − .select({ assetId: sales.assetId, grader: sales.grader, grade: sales.grade, priceUsd: sales.priceUsd })
388 + .select({ assetId: sales.assetId, grader: sales.grader, grade: sales.grade, priceUsd: BUYER_PAYS })
379 389 .from(sales)
380 390 .innerJoin(assets, eq(assets.id, sales.assetId))
381 391 .where(and(eq(assets.categorySlug, cat), eq(sales.status, 'valid'), gte(sales.saleDate, since)));
382 392