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>
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 | |