import 'dotenv/config'; import Alpaca from '@alpacahq/alpaca-trade-api'; import { createClient } from '@supabase/supabase-js'; type OrderRow = { id?: string; order_id?: string | null; symbol?: string | null; status?: string | null; qty?: number | string | null; price?: number | string | null; source?: string | null; created_at?: string | null; updated_at?: string | null; filled_at?: string | null; }; const args = process.argv.slice(2); const applyMode = args.includes('--apply'); const lookbackArg = args.find((arg) => arg.startsWith('--lookback-hours=')); const LOOKBACK_HOURS = Number((lookbackArg ? lookbackArg.split('=')[1] : '') || '48'); const PAGE_SIZE = 1000; const SYNTHETIC_PREFIXES = ['BFILL-', 'MANOVR-', 'RECON-BF', 'RECON-', 'SYNC-']; const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); const supabaseKey = String( process.env.SUPABASE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY || '' ).trim(); const alpacaApiKey = String(process.env.ALPACA_API_KEY || '').trim(); const alpacaApiSecret = String(process.env.ALPACA_API_SECRET || '').trim(); const paperTrading = String(process.env.PAPER_TRADING || 'true').toLowerCase() === 'true'; if (!supabaseUrl || !supabaseKey) { throw new Error('Missing Supabase credentials.'); } if (!alpacaApiKey || !alpacaApiSecret) { throw new Error('Missing Alpaca credentials.'); } if (!Number.isFinite(LOOKBACK_HOURS) || LOOKBACK_HOURS <= 0) { throw new Error(`Invalid --lookback-hours value: ${lookbackArg}`); } const supabase = createClient(supabaseUrl, supabaseKey); const alpaca = new (Alpaca as any)({ keyId: alpacaApiKey, secretKey: alpacaApiSecret, paper: paperTrading }); const toNumber = (value: unknown): number => { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : 0; }; const normalizeStatus = (value: unknown): string => { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'partially-filled') return 'partially_filled'; return normalized; }; const isSyntheticOrderId = (orderId: string): boolean => { const normalized = String(orderId || '').trim().toUpperCase(); if (!normalized) return false; return SYNTHETIC_PREFIXES.some((prefix) => normalized.startsWith(prefix)); }; const pctDiff = (left: number, right: number): number => { const denom = Math.max(Math.abs(left), Math.abs(right), 1e-9); return Math.abs(left - right) / denom; }; const run = async () => { const sinceIso = new Date(Date.now() - LOOKBACK_HOURS * 60 * 60 * 1000).toISOString(); const rows: OrderRow[] = []; let offset = 0; for (;;) { const { data, error } = await supabase .from('orders') .select('id,order_id,symbol,status,qty,price,source,created_at,updated_at,filled_at') .in('status', ['filled', 'partially_filled', 'partially-filled']) .gte('created_at', sinceIso) .order('created_at', { ascending: false }) .range(offset, offset + PAGE_SIZE - 1); if (error) throw error; const chunk = (data || []) as OrderRow[]; if (!chunk.length) break; rows.push(...chunk); if (chunk.length < PAGE_SIZE) break; offset += PAGE_SIZE; } const candidates = rows.filter((row) => { const orderId = String(row.order_id || '').trim(); if (!orderId) return false; if (isSyntheticOrderId(orderId)) return false; const source = String(row.source || '').trim().toUpperCase(); if (source === 'MANUAL') return false; return true; }); const summary = { mode: applyMode ? 'apply' : 'dry-run', lookbackHours: LOOKBACK_HOURS, sinceIso, totalRows: rows.length, candidates: candidates.length, checked: 0, exchangeMissing: 0, noFillOnExchange: 0, driftDetected: 0, updated: 0, samples: [] as Array> }; for (const row of candidates) { const orderId = String(row.order_id || '').trim(); summary.checked += 1; let exchangeOrder: any; try { exchangeOrder = await (alpaca as any).getOrder(orderId); } catch { summary.exchangeMissing += 1; continue; } if (!exchangeOrder) { summary.exchangeMissing += 1; continue; } const exchangeStatus = normalizeStatus(exchangeOrder.status); const exchangeQty = toNumber(exchangeOrder.filled_qty ?? exchangeOrder.filledQty ?? exchangeOrder.filled_quantity); const exchangePrice = toNumber(exchangeOrder.filled_avg_price); if (!(exchangeQty > 0) || !(exchangePrice > 0)) { summary.noFillOnExchange += 1; continue; } const dbStatus = normalizeStatus(row.status); const dbQty = toNumber(row.qty); const dbPrice = toNumber(row.price); const qtyDrift = dbQty <= 0 || pctDiff(exchangeQty, dbQty) > 1e-6; const priceDrift = dbPrice <= 0 || pctDiff(exchangePrice, dbPrice) > 5e-4; const statusDrift = exchangeStatus && exchangeStatus !== dbStatus; if (!qtyDrift && !priceDrift && !statusDrift) continue; summary.driftDetected += 1; if (summary.samples.length < 20) { summary.samples.push({ order_id: orderId, symbol: row.symbol, db_status: row.status, ex_status: exchangeOrder.status, db_qty: dbQty, ex_qty: exchangeQty, db_price: dbPrice, ex_price: exchangePrice }); } if (!applyMode) continue; const filledAtRaw = String(exchangeOrder.filled_at || '').trim(); const parsedFilledAt = filledAtRaw ? Date.parse(filledAtRaw) : NaN; const filledAtIso = Number.isFinite(parsedFilledAt) && parsedFilledAt > 0 ? new Date(parsedFilledAt).toISOString() : new Date().toISOString(); const { error: updateError } = await supabase .from('orders') .update({ status: exchangeStatus || 'filled', qty: exchangeQty, price: exchangePrice, filled_at: filledAtIso, updated_at: new Date().toISOString() }) .eq('order_id', orderId); if (updateError) throw updateError; summary.updated += 1; } console.log(JSON.stringify(summary, null, 2)); }; run().catch((error) => { console.error(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)); process.exit(1); });