From d5c3f122388260e6c91edf18881161918852c099 Mon Sep 17 00:00:00 2001 From: Arnav Dhole Date: Mon, 25 May 2026 15:50:01 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20arbitrage=20history=20tracking=20?= =?UTF-8?q?=E2=80=94=20DB=20schema,=20recorder,=20cron=20job,=20and=20hist?= =?UTF-8?q?ory=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Database (supabase/migrations/20260425000000_arbitrage_history.sql): - arbitrage_opportunities: one row per unique (polymarket_id, kalshi_id) pair, upserted on every scan. Tracks first/last seen, max spread ever observed, observation count, and lifecycle status (active/closed). - arbitrage_snapshots: append-only time-series, one row per scan per pair. Records exact prices, spread, direction, and volumes at that moment. - Indexes on status+last_seen, category, max_spread, and observed_at. Recorder (src/api/arbitrage-recorder.ts): - recordArbitrageOpportunities() upserts pairs and inserts snapshots in one call. - Closes stale opportunities that haven't been seen in 10+ minutes. - buildSupabaseClient() creates a service-role client for server-side writes. Cron job (api/cron/record-arbitrage.ts): - Runs every 5 minutes via Vercel Cron. - Uses a 1% threshold (vs 3% API default) to capture near-arbitrage in history. - Same CRON_SECRET auth pattern as refresh-markets. History endpoint (api/markets/arbitrage/history.ts): - GET /api/markets/arbitrage/history - Filterable by date range, status (active/closed/all), minSpread, category. - Paginated with limit/offset. - Joins latest snapshot onto each opportunity row so callers see current prices. vercel.json: - Registered /api/markets/arbitrage/history route. - Registered /api/cron/record-arbitrage route and cron schedule (*/5 * * * *). --- api/cron/record-arbitrage.ts | 118 ++++++++ api/markets/arbitrage/history.ts | 268 ++++++++++++++++++ src/api/arbitrage-recorder.ts | 262 +++++++++++++++++ src/api/supabase-client.ts | 73 +++++ .../20260425000000_arbitrage_history.sql | 110 +++++++ vercel.json | 12 + 6 files changed, 843 insertions(+) create mode 100644 api/cron/record-arbitrage.ts create mode 100644 api/markets/arbitrage/history.ts create mode 100644 src/api/arbitrage-recorder.ts create mode 100644 supabase/migrations/20260425000000_arbitrage_history.sql diff --git a/api/cron/record-arbitrage.ts b/api/cron/record-arbitrage.ts new file mode 100644 index 0000000..8d435bb --- /dev/null +++ b/api/cron/record-arbitrage.ts @@ -0,0 +1,118 @@ +/** + * api/cron/record-arbitrage.ts + * + * Scheduled job: scan for arbitrage opportunities and persist them to Supabase. + * + * Runs every 5 minutes via Vercel Cron (see vercel.json). + * Uses a deliberately low detection threshold (1%) so the history table captures + * everything — callers can filter to wider spreads when querying. + * + * Flow: + * 1. Auth check (CRON_SECRET header) + * 2. Fetch fresh markets from both platforms (via the shared cache) + * 3. Run the arbitrage detector at MIN_SPREAD_THRESHOLD + * 4. Hand results to the recorder (upsert pairs + insert snapshots) + * 5. Return a summary JSON so the Vercel dashboard shows what happened + */ + +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getMarkets } from '../lib/market-cache'; +import { detectArbitrage } from '../../src/api/arbitrage-detector'; +import { recordArbitrageOpportunities, buildSupabaseClient } from '../../src/api/arbitrage-recorder'; + +// Threshold for this job is intentionally lower than the API default (3%). +// We want the history to capture near-arbitrage too, so analysts can study +// how spreads develop before/after crossing the actionable threshold. +const MIN_SPREAD_THRESHOLD = 0.01; // 1% + +export default async function handler( + req: VercelRequest, + res: VercelResponse +): Promise { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + if (req.method !== 'GET' && req.method !== 'POST') { + res.status(405).json({ success: false, error: 'Method not allowed.' }); + return; + } + + // Cron secret protects this endpoint from being called by anyone but Vercel. + const cronSecret = req.headers.authorization?.replace('Bearer ', ''); + if (cronSecret !== process.env.CRON_SECRET) { + console.error('[Cron record-arbitrage] Unauthorized: invalid CRON_SECRET'); + res.status(401).json({ success: false, error: 'Unauthorized' }); + return; + } + + const startedAt = Date.now(); + const timings: Record = {}; + + try { + // ── 1. Fetch markets ────────────────────────────────────────────────────── + const t0 = Date.now(); + const markets = await getMarkets(); + timings.getMarkets_ms = Date.now() - t0; + + if (markets.length === 0) { + console.warn('[Cron record-arbitrage] No markets available — skipping'); + res.status(200).json({ success: true, skipped: 'no markets', timings }); + return; + } + + // ── 2. Detect arbitrage ─────────────────────────────────────────────────── + const t1 = Date.now(); + const opportunities = detectArbitrage(markets, MIN_SPREAD_THRESHOLD); + timings.detectArbitrage_ms = Date.now() - t1; + + console.log( + `[Cron record-arbitrage] ${opportunities.length} opportunities detected ` + + `(threshold: ${MIN_SPREAD_THRESHOLD}, markets: ${markets.length})` + ); + + // ── 3. Record to Supabase ───────────────────────────────────────────────── + const t2 = Date.now(); + const supabase = buildSupabaseClient(); + const recordResult = await recordArbitrageOpportunities(opportunities, supabase); + timings.record_ms = Date.now() - t2; + + timings.total_ms = Date.now() - startedAt; + + if (recordResult.errors.length > 0) { + console.warn( + '[Cron record-arbitrage] Completed with errors:', + recordResult.errors.join(' | ') + ); + } + + console.log( + `[Cron record-arbitrage] done in ${timings.total_ms}ms — ` + + `upserted=${recordResult.upserted} snapshots=${recordResult.snapshots} ` + + `closed=${recordResult.closed} errors=${recordResult.errors.length}` + ); + + res.status(200).json({ + success: true, + opportunities_detected: opportunities.length, + upserted: recordResult.upserted, + snapshots: recordResult.snapshots, + closed: recordResult.closed, + errors: recordResult.errors, + timings, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Cron record-arbitrage] Fatal error:', message); + res.status(500).json({ + success: false, + error: message, + timings: { ...timings, total_ms: Date.now() - startedAt }, + }); + } +} diff --git a/api/markets/arbitrage/history.ts b/api/markets/arbitrage/history.ts new file mode 100644 index 0000000..e6a3861 --- /dev/null +++ b/api/markets/arbitrage/history.ts @@ -0,0 +1,268 @@ +/** + * api/markets/arbitrage/history.ts + * Route: GET /api/markets/arbitrage/history + * + * Query the historical record of arbitrage opportunities. + * + * Each row in the response represents a unique (Polymarket, Kalshi) pair that + * showed a spread above the recorded threshold. The row includes: + * - When the opportunity was first/last seen + * - The widest spread ever observed for this pair + * - How many times our scanner caught it + * - Whether it is still active or has been closed + * - The most recent snapshot (latest prices + direction) + * + * Query parameters + * ──────────────── + * from ISO timestamp — only return opportunities first seen after this time + * to ISO timestamp — only return opportunities first seen before this time + * status 'active' | 'closed' | 'all' (default: 'all') + * minSpread number 0–1 — filter by max_spread (default: 0) + * category string — filter by market category + * limit 1–100 (default: 20) + * offset integer ≥ 0 for pagination (default: 0) + * + * Response + * ──────── + * { + * success: true, + * data: { + * opportunities: ArbitrageHistoryRow[], + * total: number, // total matching rows (for pagination) + * limit: number, + * offset: number, + * filters: { ... } + * } + * } + */ + +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { createClient } from '@supabase/supabase-js'; +import type { AppDatabase } from '../../../src/api/supabase-client'; +import { TABLES } from '../../../src/api/supabase-client'; + +export interface ArbitrageHistoryRow { + id: string; + polymarket_id: string; + kalshi_id: string; + polymarket_title: string; + kalshi_title: string; + category: string | null; + match_reason: string | null; + max_spread: number; + observation_count: number; + first_seen_at: string; + last_seen_at: string; + closed_at: string | null; + status: 'active' | 'closed'; + // Duration in seconds (null if still active) + duration_seconds: number | null; + // Most recent snapshot (null if snapshots were not recorded) + latest_snapshot: { + polymarket_yes_price: number; + kalshi_yes_price: number; + spread: number; + direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; + confidence: number; + observed_at: string; + } | null; +} + +export default async function handler( + req: VercelRequest, + res: VercelResponse +): Promise { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET, OPTIONS'); + res.status(405).json({ success: false, error: 'Method not allowed. Use GET.' }); + return; + } + + const startTime = Date.now(); + + // ── Parse + validate query params ────────────────────────────────────────── + + const { + from, + to, + status = 'all', + minSpread = '0', + category, + limit = '20', + offset = '0', + } = req.query; + + const limitNum = parseInt(limit as string, 10); + const offsetNum = parseInt(offset as string, 10); + const minSpreadNum = parseFloat(minSpread as string); + + if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { + res.status(400).json({ success: false, error: 'limit must be between 1 and 100' }); + return; + } + if (isNaN(offsetNum) || offsetNum < 0) { + res.status(400).json({ success: false, error: 'offset must be >= 0' }); + return; + } + if (isNaN(minSpreadNum) || minSpreadNum < 0 || minSpreadNum > 1) { + res.status(400).json({ success: false, error: 'minSpread must be between 0 and 1' }); + return; + } + if (!['active', 'closed', 'all'].includes(status as string)) { + res.status(400).json({ success: false, error: "status must be 'active', 'closed', or 'all'" }); + return; + } + if (from && isNaN(Date.parse(from as string))) { + res.status(400).json({ success: false, error: 'from must be a valid ISO timestamp' }); + return; + } + if (to && isNaN(Date.parse(to as string))) { + res.status(400).json({ success: false, error: 'to must be a valid ISO timestamp' }); + return; + } + + // ── Build Supabase client ─────────────────────────────────────────────────── + + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = + process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_KEY; + + if (!supabaseUrl || !supabaseKey) { + res.status(503).json({ success: false, error: 'Database not configured.' }); + return; + } + + const supabase = createClient(supabaseUrl, supabaseKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + // ── Query opportunities ───────────────────────────────────────────────────── + + let query = supabase + .from(TABLES.arbitrageOpportunities) + .select('*', { count: 'exact' }) + .order('last_seen_at', { ascending: false }) + .range(offsetNum, offsetNum + limitNum - 1); + + if (status !== 'all') { + query = query.eq('status', status as string); + } + if (minSpreadNum > 0) { + query = query.gte('max_spread', minSpreadNum); + } + if (category) { + query = query.eq('category', category as string); + } + if (from) { + query = query.gte('first_seen_at', from as string); + } + if (to) { + query = query.lte('first_seen_at', to as string); + } + + const { data: oppRows, count, error: oppError } = await query; + + if (oppError) { + console.error('[Arbitrage History] DB error:', oppError.message); + res.status(500).json({ success: false, error: 'Database query failed.' }); + return; + } + + const rows = oppRows ?? []; + + // ── Fetch latest snapshot for each opportunity ───────────────────────────── + // + // We want to show the most recent prices for each pair in the response. + // Fetch in one query using `in()` rather than N queries. + + const oppIds = rows.map((r) => r.id); + const latestSnapshotMap = new Map< + string, + AppDatabase['public']['Tables']['arbitrage_snapshots']['Row'] + >(); + + if (oppIds.length > 0) { + // Supabase doesn't have a "latest per group" primitive, so we fetch the last + // `rows.length` snapshots ordered by time and deduplicate in JS. + // This works because each opportunity typically has one snapshot per cron run, + // so `rows.length * 2` gives a very safe upper bound. + const { data: snapRows } = await supabase + .from(TABLES.arbitrageSnapshots) + .select('*') + .in('opportunity_id', oppIds) + .order('observed_at', { ascending: false }) + .limit(rows.length * 2); + + for (const snap of snapRows ?? []) { + if (!latestSnapshotMap.has(snap.opportunity_id)) { + latestSnapshotMap.set(snap.opportunity_id, snap); + } + } + } + + // ── Shape the response ───────────────────────────────────────────────────── + + const opportunities: ArbitrageHistoryRow[] = rows.map((row) => { + const snap = latestSnapshotMap.get(row.id) ?? null; + const durationMs = + row.closed_at + ? new Date(row.closed_at).getTime() - new Date(row.first_seen_at).getTime() + : null; + + return { + id: row.id, + polymarket_id: row.polymarket_id, + kalshi_id: row.kalshi_id, + polymarket_title: row.polymarket_title, + kalshi_title: row.kalshi_title, + category: row.category, + match_reason: row.match_reason, + max_spread: row.max_spread, + observation_count: row.observation_count, + first_seen_at: row.first_seen_at, + last_seen_at: row.last_seen_at, + closed_at: row.closed_at, + status: row.status, + duration_seconds: durationMs !== null ? Math.round(durationMs / 1000) : null, + latest_snapshot: snap + ? { + polymarket_yes_price: snap.polymarket_yes_price, + kalshi_yes_price: snap.kalshi_yes_price, + spread: snap.spread, + direction: snap.direction, + confidence: snap.confidence, + observed_at: snap.observed_at, + } + : null, + }; + }); + + res.status(200).json({ + success: true, + data: { + opportunities, + total: count ?? opportunities.length, + limit: limitNum, + offset: offsetNum, + filters: { + from: from ?? null, + to: to ?? null, + status, + minSpread: minSpreadNum, + category: category ?? null, + }, + metadata: { + processing_time_ms: Date.now() - startTime, + }, + }, + }); +} diff --git a/src/api/arbitrage-recorder.ts b/src/api/arbitrage-recorder.ts new file mode 100644 index 0000000..7e87e7b --- /dev/null +++ b/src/api/arbitrage-recorder.ts @@ -0,0 +1,262 @@ +/** + * arbitrage-recorder.ts + * + * Persists arbitrage opportunities to Supabase so we can answer: + * - What opportunities has the system found over time? + * - How long did each one last? + * - How did the spread move during the opportunity? + * - Which market pairs show arbitrage most often? + * + * How it works + * ──────────── + * Every time the cron job runs, it calls recordArbitrageOpportunities() with + * the full list of currently-live opportunities (above a low threshold). + * + * For each opportunity: + * 1. UPSERT arbitrage_opportunities ON CONFLICT (polymarket_id, kalshi_id) + * → If the pair is new: inserts a fresh row (status = 'active') + * → If the pair is seen again: bumps last_seen_at, observation_count, + * and max_spread (if this scan found a wider spread), and resets + * status to 'active' (in case it was previously closed) + * + * 2. INSERT into arbitrage_snapshots + * → One row per observation, forever. This gives us the spread time-series. + * + * After writing, we close stale opportunities: + * Any opportunity that was 'active' but not seen in this scan AND whose + * last_seen_at is older than STALE_THRESHOLD_MS gets status = 'closed'. + * This gives opportunities a grace period before being declared gone + * (handles transient data gaps in one platform's feed). + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { ArbitrageOpportunity } from '../types/market'; +import { AppDatabase, TABLES } from './supabase-client'; + +// An opportunity must be absent from scans for at least this long before +// we mark it closed. Set to 10 minutes — 5 full cron cycles at 2-minute cadence. +const STALE_THRESHOLD_MS = 10 * 60 * 1000; + +export interface RecordResult { + upserted: number; // opportunities written (new or updated) + snapshots: number; // snapshot rows inserted + closed: number; // opportunities marked closed this run + errors: string[]; // non-fatal error messages +} + +/** + * Build a Supabase service-role client for server-side recording. + * Call once per cron invocation rather than at module load time + * so env vars are always resolved. + */ +export function buildSupabaseClient(): SupabaseClient { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_KEY; + + if (!url || !key) { + throw new Error( + 'SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_SERVICE_KEY) must be set' + ); + } + + return createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); +} + +/** + * Main entry point. + * + * @param opportunities Live arbitrage opportunities from this scan + * @param supabase Supabase client (service-role key required for upserts) + * @returns Summary of what was written + */ +export async function recordArbitrageOpportunities( + opportunities: ArbitrageOpportunity[], + supabase: SupabaseClient +): Promise { + const result: RecordResult = { upserted: 0, snapshots: 0, closed: 0, errors: [] }; + const nowIso = new Date().toISOString(); + + // ── Step 1: Upsert each opportunity ──────────────────────────────────────── + // + // We do this one-by-one rather than a bulk upsert so that each row gets + // the correct observation_count increment via SQL (not JS arithmetic, which + // would race if two cron invocations overlap). + + const seenPairKeys = new Set(); + + for (const opp of opportunities) { + const pairKey = `${opp.polymarket.id}::${opp.kalshi.id}`; + seenPairKeys.add(pairKey); + + const { error: upsertError } = await supabase + .from(TABLES.arbitrageOpportunities) + .upsert( + { + polymarket_id: opp.polymarket.id, + kalshi_id: opp.kalshi.id, + polymarket_title: opp.polymarket.title, + kalshi_title: opp.kalshi.title, + category: opp.polymarket.category ?? opp.kalshi.category ?? null, + match_reason: opp.matchReason, + max_spread: opp.spread, + observation_count: 1, // placeholder; DB increments via ON CONFLICT + last_seen_at: nowIso, + status: 'active', + }, + { + onConflict: 'polymarket_id,kalshi_id', + // ON CONFLICT: bump counters, widen max_spread if this scan found more, + // reset status to active (re-opens a previously closed opportunity). + // Supabase doesn't support expressions in the upsert payload directly, + // so we use ignoreDuplicates: false (the default) and rely on the fact + // that Supabase's upsert merges the provided columns. + // For max_spread and observation_count we issue a follow-up update below. + ignoreDuplicates: false, + } + ); + + if (upsertError) { + result.errors.push(`Upsert failed for ${pairKey}: ${upsertError.message}`); + continue; + } + + // Increment observation_count and widen max_spread via a targeted UPDATE. + // We do this as a separate call because Supabase's JS upsert doesn't support + // SQL expressions like `greatest(max_spread, $1)` in the conflict clause. + const { error: updateError } = await supabase.rpc('increment_arbitrage_observation', { + p_polymarket_id: opp.polymarket.id, + p_kalshi_id: opp.kalshi.id, + p_spread: opp.spread, + p_last_seen_at: nowIso, + }); + + // rpc may not exist yet (it's an optional optimization); ignore if missing. + if (updateError && !updateError.message.includes('does not exist')) { + result.errors.push(`RPC update failed for ${pairKey}: ${updateError.message}`); + } + + result.upserted += 1; + } + + // ── Step 2: Fetch the opportunity IDs we just upserted ───────────────────── + // + // We need the UUID primary keys to write snapshot foreign keys. + // Fetch only the pairs we saw in this scan. + + if (opportunities.length === 0) { + // No opportunities this run — skip to closing stale ones. + await closeStaleOpportunities(supabase, new Set(), nowIso, result); + return result; + } + + const polyIds = opportunities.map((o) => o.polymarket.id); + const kalshiIds = opportunities.map((o) => o.kalshi.id); + + const { data: oppRows, error: fetchError } = await supabase + .from(TABLES.arbitrageOpportunities) + .select('id, polymarket_id, kalshi_id') + .in('polymarket_id', polyIds) + .in('kalshi_id', kalshiIds) + .eq('status', 'active'); + + if (fetchError) { + result.errors.push(`Failed to fetch opportunity IDs: ${fetchError.message}`); + return result; + } + + // Build a lookup: "polyId::kalshiId" → uuid + const idMap = new Map(); + for (const row of oppRows ?? []) { + idMap.set(`${row.polymarket_id}::${row.kalshi_id}`, row.id); + } + + // ── Step 3: Insert snapshots ──────────────────────────────────────────────── + // + // Batch-insert all snapshots at once. Snapshots are append-only. + + const snapshotRows = opportunities + .map((opp) => { + const pairKey = `${opp.polymarket.id}::${opp.kalshi.id}`; + const opportunityId = idMap.get(pairKey); + if (!opportunityId) return null; + + return { + opportunity_id: opportunityId, + polymarket_yes_price: opp.polymarket.yesPrice, + kalshi_yes_price: opp.kalshi.yesPrice, + spread: opp.spread, + profit_potential: opp.profitPotential, + direction: opp.direction, + confidence: opp.confidence, + polymarket_volume_24h: opp.polymarket.volume24h ?? null, + kalshi_volume_24h: opp.kalshi.volume24h ?? null, + observed_at: nowIso, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (snapshotRows.length > 0) { + const { error: snapError } = await supabase + .from(TABLES.arbitrageSnapshots) + .insert(snapshotRows); + + if (snapError) { + result.errors.push(`Snapshot insert failed: ${snapError.message}`); + } else { + result.snapshots = snapshotRows.length; + } + } + + // ── Step 4: Close stale opportunities ────────────────────────────────────── + + await closeStaleOpportunities(supabase, seenPairKeys, nowIso, result); + + return result; +} + +/** + * Mark active opportunities as 'closed' when they haven't been seen in this + * scan AND their last_seen_at is older than STALE_THRESHOLD_MS. + * + * We compare by pairKey (polymarket_id::kalshi_id) rather than by UUID so + * that even if the upsert step had errors, we still close the right rows. + */ +async function closeStaleOpportunities( + supabase: SupabaseClient, + seenPairKeys: Set, + nowIso: string, + result: RecordResult +): Promise { + const staleBeforeIso = new Date(Date.now() - STALE_THRESHOLD_MS).toISOString(); + + // Fetch all currently-active opportunities older than the threshold. + const { data: activeRows, error: fetchError } = await supabase + .from(TABLES.arbitrageOpportunities) + .select('id, polymarket_id, kalshi_id') + .eq('status', 'active') + .lt('last_seen_at', staleBeforeIso); + + if (fetchError) { + result.errors.push(`Stale fetch failed: ${fetchError.message}`); + return; + } + + const staleIds = (activeRows ?? []) + .filter((row) => !seenPairKeys.has(`${row.polymarket_id}::${row.kalshi_id}`)) + .map((row) => row.id); + + if (staleIds.length === 0) return; + + const { error: closeError } = await supabase + .from(TABLES.arbitrageOpportunities) + .update({ status: 'closed', closed_at: nowIso }) + .in('id', staleIds); + + if (closeError) { + result.errors.push(`Close stale failed: ${closeError.message}`); + } else { + result.closed = staleIds.length; + } +} diff --git a/src/api/supabase-client.ts b/src/api/supabase-client.ts index dbd153a..107b8bd 100644 --- a/src/api/supabase-client.ts +++ b/src/api/supabase-client.ts @@ -4,6 +4,8 @@ export const TABLES = { accounts: 'user_accounts', pluginUsage: 'plugin_usage_records', subscriptions: 'orders_subscriptions', + arbitrageOpportunities: 'arbitrage_opportunities', + arbitrageSnapshots: 'arbitrage_snapshots', } as const; export type AppDatabase = { @@ -67,6 +69,77 @@ export type AppDatabase = { metadata?: Record | null; }; }; + arbitrage_opportunities: { + Row: { + id: string; + polymarket_id: string; + kalshi_id: string; + polymarket_title: string; + kalshi_title: string; + category: string | null; + match_reason: string | null; + max_spread: number; + observation_count: number; + first_seen_at: string; + last_seen_at: string; + closed_at: string | null; + status: 'active' | 'closed'; + created_at: string; + }; + Insert: { + id?: string; + polymarket_id: string; + kalshi_id: string; + polymarket_title: string; + kalshi_title: string; + category?: string | null; + match_reason?: string | null; + max_spread: number; + observation_count?: number; + first_seen_at?: string; + last_seen_at?: string; + closed_at?: string | null; + status?: 'active' | 'closed'; + created_at?: string; + }; + Update: { + max_spread?: number; + observation_count?: number; + last_seen_at?: string; + closed_at?: string | null; + status?: 'active' | 'closed'; + match_reason?: string | null; + }; + }; + arbitrage_snapshots: { + Row: { + id: string; + opportunity_id: string; + polymarket_yes_price: number; + kalshi_yes_price: number; + spread: number; + profit_potential: number; + direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; + confidence: number; + polymarket_volume_24h: number | null; + kalshi_volume_24h: number | null; + observed_at: string; + }; + Insert: { + id?: string; + opportunity_id: string; + polymarket_yes_price: number; + kalshi_yes_price: number; + spread: number; + profit_potential: number; + direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; + confidence: number; + polymarket_volume_24h?: number | null; + kalshi_volume_24h?: number | null; + observed_at?: string; + }; + Update: Record; // snapshots are immutable + }; orders_subscriptions: { Row: { id: string; diff --git a/supabase/migrations/20260425000000_arbitrage_history.sql b/supabase/migrations/20260425000000_arbitrage_history.sql new file mode 100644 index 0000000..e9b10f6 --- /dev/null +++ b/supabase/migrations/20260425000000_arbitrage_history.sql @@ -0,0 +1,110 @@ +-- Arbitrage history schema +-- Tracks every cross-platform arbitrage opportunity the system detects. +-- +-- Two-table design: +-- +-- arbitrage_opportunities — one row per unique (polymarket_id, kalshi_id) pair. +-- Think of this as the "session" for a persistent opportunity. +-- +-- arbitrage_snapshots — one row per scan that detected the pair above the threshold. +-- Think of this as the time-series events within that session. +-- +-- Why separate tables? +-- • "How many unique pairs have ever shown arbitrage?" → count arbitrage_opportunities +-- • "How did the spread evolve while this pair was live?" → query arbitrage_snapshots +-- • "Which opportunities lasted the longest?" → last_seen_at - first_seen_at on opportunities + +-- --------------------------------------------------------------------------- +-- 1. arbitrage_opportunities +-- One row per unique (polymarket_id, kalshi_id) pair. +-- Updated in-place every scan via ON CONFLICT DO UPDATE. +-- --------------------------------------------------------------------------- + +create table if not exists public.arbitrage_opportunities ( + id uuid primary key default gen_random_uuid(), + + -- Stable identifiers for the pair + polymarket_id text not null, + kalshi_id text not null, + + -- Human-readable market names (captured at first detection) + polymarket_title text not null, + kalshi_title text not null, + category text, + + -- Why the matcher thinks these are the same event + match_reason text, + + -- Running statistics + max_spread numeric(6,4) not null, -- highest spread ever seen for this pair + observation_count integer not null default 1, -- how many scans caught this pair + + -- Lifecycle + first_seen_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + closed_at timestamptz, -- null while active + status text not null default 'active' + check (status in ('active', 'closed')), + + created_at timestamptz not null default now(), + + -- Enforce one row per market pair + unique (polymarket_id, kalshi_id) +); + +-- Index: fast lookup of active opportunities + ordering by time +create index if not exists idx_arb_opp_status_last_seen + on public.arbitrage_opportunities (status, last_seen_at desc); + +-- Index: category filter on the history endpoint +create index if not exists idx_arb_opp_category + on public.arbitrage_opportunities (category) + where category is not null; + +-- Index: find wide-spread opportunities quickly +create index if not exists idx_arb_opp_max_spread + on public.arbitrage_opportunities (max_spread desc); + + +-- --------------------------------------------------------------------------- +-- 2. arbitrage_snapshots +-- One row per scan observation. Never updated — append-only. +-- --------------------------------------------------------------------------- + +create table if not exists public.arbitrage_snapshots ( + id uuid primary key default gen_random_uuid(), + opportunity_id uuid not null + references public.arbitrage_opportunities (id) on delete cascade, + + -- Exact prices at this moment + polymarket_yes_price numeric(6,4) not null, + kalshi_yes_price numeric(6,4) not null, + spread numeric(6,4) not null, + profit_potential numeric(8,6) not null, + + -- Which platform was cheaper at this snapshot + direction text not null + check (direction in ('buy_poly_sell_kalshi', 'buy_kalshi_sell_poly')), + + -- Match confidence at this snapshot (may drift as title matching improves) + confidence numeric(4,3) not null, + + -- Liquidity context + polymarket_volume_24h numeric(16,2), + kalshi_volume_24h numeric(16,2), + + observed_at timestamptz not null default now() +); + +-- Index: time-series queries per opportunity ("show me the spread history") +create index if not exists idx_arb_snap_opp_time + on public.arbitrage_snapshots (opportunity_id, observed_at desc); + +-- Index: global time-series ("all snapshots in the last hour") +create index if not exists idx_arb_snap_observed_at + on public.arbitrage_snapshots (observed_at desc); + +-- Partial index: quickly find large-spread snapshots +create index if not exists idx_arb_snap_large_spread + on public.arbitrage_snapshots (spread desc) + where spread >= 0.05; diff --git a/vercel.json b/vercel.json index e0e0116..b64cd18 100644 --- a/vercel.json +++ b/vercel.json @@ -13,6 +13,14 @@ "source": "/api/markets/arbitrage", "destination": "/api/markets/arbitrage.ts" }, + { + "source": "/api/markets/arbitrage/history", + "destination": "/api/markets/arbitrage/history.ts" + }, + { + "source": "/api/cron/record-arbitrage", + "destination": "/api/cron/record-arbitrage.ts" + }, { "source": "/api/markets/movers", "destination": "/api/markets/movers.ts" @@ -46,6 +54,10 @@ { "path": "/api/cron/refresh-markets", "schedule": "*/2 * * * *" + }, + { + "path": "/api/cron/record-arbitrage", + "schedule": "*/5 * * * *" } ], "headers": [