import 'dotenv/config'; import { createClient } from '@supabase/supabase-js'; type OrderRow = { id: string; order_id?: string | null; user_id?: string | null; profile_id?: string | null; symbol?: string | null; trade_id?: string | null; action?: string | null; side?: string | null; qty?: number | string | null; price?: number | string | null; status?: string | null; created_at?: string | null; }; type HistoryRow = { id: string; user_id?: string | null; profile_id?: string | null; symbol?: string | null; trade_id?: string | null; side?: string | null; size?: number | string | null; entry_price?: number | string | null; exit_price?: number | string | null; pnl?: number | string | null; pnl_percent?: number | string | null; reason?: string | null; source?: string | null; timestamp?: number | string | null; created_at?: string | null; }; type CanonicalTrade = { tradeId: string; userId: string | null; profileId: string | null; symbol: string; side: 'BUY' | 'SELL'; closedQty: number; avgEntry: number; avgExit: number; pnl: number; pnlPercent: number; }; const PAGE_SIZE = 1000; const EPS = 1e-8; const DEFAULT_START = '2026-02-12T00:00:00.000Z'; const CANONICAL_REASON = '[RECONCILED_CANONICAL] Order lifecycle reconciled from orders'; const ZEROED_PREFIX = '[RECONCILED_TO_ORDERS]'; const args = process.argv.slice(2); const applyMode = args.includes('--apply'); const startArg = args.find((arg) => arg.startsWith('--start=')); const startIso = startArg ? String(startArg.split('=')[1] || '').trim() : DEFAULT_START; const tradeIds = args.filter((arg) => !arg.startsWith('--')); 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(); if (!supabaseUrl || !supabaseKey) { throw new Error('Missing Supabase credentials. Expected SUPABASE_URL + SUPABASE_KEY/SUPABASE_SERVICE_ROLE_KEY.'); } const supabase = createClient(supabaseUrl, supabaseKey); const toNumber = (value: unknown): number => { const num = Number(value); return Number.isFinite(num) ? num : 0; }; const normalizeSide = (side: string | null | undefined): 'BUY' | 'SELL' => { const normalized = String(side || '').trim().toUpperCase(); return normalized === 'SELL' || normalized === 'SHORT' ? 'SELL' : 'BUY'; }; const normalizeAction = (action: string | null | undefined): 'ENTRY' | 'EXIT' | undefined => { const normalized = String(action || '').trim().toUpperCase(); if (normalized === 'ENTRY' || normalized === 'EXIT') return normalized; return undefined; }; const inferAction = (row: OrderRow): 'ENTRY' | 'EXIT' | undefined => { const explicit = normalizeAction(row.action); if (explicit) return explicit; if (String(row.trade_id || '').trim().length === 0) return undefined; return normalizeSide(row.side) === 'BUY' ? 'ENTRY' : 'EXIT'; }; const startsWithIgnoreCase = (value: string | null | undefined, prefix: string): boolean => { return String(value || '').toLowerCase().startsWith(prefix.toLowerCase()); }; const sortByCreatedAt = (rows: T[]): T[] => { return [...rows].sort((a, b) => { const aTs = Date.parse(String(a.created_at || '')) || 0; const bTs = Date.parse(String(b.created_at || '')) || 0; return aTs - bTs; }); }; const fetchPaged = async (table: 'orders' | 'trade_history', columns: string): Promise => { const out: T[] = []; let offset = 0; for (;;) { const { data, error } = await supabase .from(table) .select(columns) .gte('created_at', startIso) .order('created_at', { ascending: true }) .range(offset, offset + PAGE_SIZE - 1); if (error) throw error; const chunk = (data || []) as T[]; if (!chunk.length) break; out.push(...chunk); if (chunk.length < PAGE_SIZE) break; offset += PAGE_SIZE; } return out; }; const getCanonicalFromOrders = (tradeId: string, rows: OrderRow[]): CanonicalTrade | null => { const ordered = sortByCreatedAt(rows); if (!ordered.length) return null; type Lot = { qty: number; price: number }; const lots: Lot[] = []; let entrySide: 'BUY' | 'SELL' | null = null; let closedQty = 0; let closedEntryNotional = 0; let closedExitNotional = 0; let realizedPnl = 0; let userId: string | null = null; let profileId: string | null = null; let symbol = ''; for (const row of ordered) { const qty = toNumber(row.qty); const price = toNumber(row.price); if (!(qty > 0) || !(price > 0)) continue; if (!userId && row.user_id) userId = row.user_id; if (profileId === null && row.profile_id !== undefined) profileId = row.profile_id || null; if (!symbol) symbol = String(row.symbol || '').trim(); const action = inferAction(row); if (!action) continue; const side = normalizeSide(row.side); if (action === 'ENTRY') { if (!entrySide) entrySide = side; if (side !== entrySide) continue; lots.push({ qty, price }); continue; } if (!entrySide) continue; const expectedExitSide = entrySide === 'BUY' ? 'SELL' : 'BUY'; if (side !== expectedExitSide) continue; let remaining = qty; while (remaining > EPS && lots.length > 0) { const lot = lots[0]; const closeQty = Math.min(remaining, lot.qty); if (closeQty <= EPS) break; lot.qty -= closeQty; remaining -= closeQty; closedQty += closeQty; closedEntryNotional += closeQty * lot.price; closedExitNotional += closeQty * price; realizedPnl += entrySide === 'BUY' ? (price - lot.price) * closeQty : (lot.price - price) * closeQty; if (lot.qty <= EPS) lots.shift(); } } if (!(closedQty > EPS) || !(closedEntryNotional > 0) || !(closedExitNotional > 0) || !entrySide) { return null; } const avgEntry = closedEntryNotional / closedQty; const avgExit = closedExitNotional / closedQty; const pnlPercent = avgEntry > 0 ? ((avgExit - avgEntry) / avgEntry) * 100 * (entrySide === 'BUY' ? 1 : -1) : 0; return { tradeId, userId, profileId, symbol, side: entrySide, closedQty, avgEntry, avgExit, pnl: realizedPnl, pnlPercent }; }; const run = async (): Promise => { const orderColumns = 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,price,status,created_at'; const historyColumns = 'id,user_id,profile_id,symbol,trade_id,side,size,entry_price,exit_price,pnl,pnl_percent,reason,source,timestamp,created_at'; let orderRows: OrderRow[] = []; let historyRows: HistoryRow[] = []; if (tradeIds.length > 0) { for (const tradeId of tradeIds) { const { data: oData, error: oError } = await supabase .from('orders') .select(orderColumns) .eq('trade_id', tradeId) .in('status', ['filled', 'partially_filled']) .order('created_at', { ascending: true }) .limit(5000); if (oError) throw oError; orderRows.push(...((oData || []) as OrderRow[])); const { data: hData, error: hError } = await supabase .from('trade_history') .select(historyColumns) .eq('trade_id', tradeId) .order('created_at', { ascending: true }) .limit(5000); if (hError) throw hError; historyRows.push(...((hData || []) as HistoryRow[])); } } else { orderRows = await fetchPaged('orders', orderColumns); orderRows = orderRows.filter((row) => ['filled', 'partially_filled'].includes(String(row.status || '').toLowerCase())); historyRows = await fetchPaged('trade_history', historyColumns); } const ordersByTrade = new Map(); for (const row of orderRows) { const tradeId = String(row.trade_id || '').trim(); if (!tradeId) continue; const list = ordersByTrade.get(tradeId) || []; list.push(row); ordersByTrade.set(tradeId, list); } const historyByTrade = new Map(); for (const row of historyRows) { const tradeId = String(row.trade_id || '').trim(); if (!tradeId) continue; const list = historyByTrade.get(tradeId) || []; list.push(row); historyByTrade.set(tradeId, list); } const targets: Array<{ tradeId: string; canonical: CanonicalTrade; currentPnl: number; diff: number; history: HistoryRow[]; }> = []; for (const [tradeId, rows] of ordersByTrade.entries()) { const canonical = getCanonicalFromOrders(tradeId, rows); if (!canonical) continue; const history = historyByTrade.get(tradeId) || []; const currentPnl = history.reduce((sum, row) => sum + toNumber(row.pnl), 0); const diff = Number((canonical.pnl - currentPnl).toFixed(8)); if (Math.abs(diff) <= 0.02) continue; targets.push({ tradeId, canonical, currentPnl, diff, history }); } targets.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); console.log(JSON.stringify({ mode: applyMode ? 'apply' : 'dry-run', startIso, explicitTradeIds: tradeIds.length > 0 ? tradeIds : null, totalOrders: orderRows.length, totalHistory: historyRows.length, targets: targets.map((target) => ({ trade_id: target.tradeId, current_history_pnl: Number(target.currentPnl.toFixed(8)), canonical_order_pnl: Number(target.canonical.pnl.toFixed(8)), diff_order_minus_history: target.diff, history_rows: target.history.length })) }, null, 2)); if (!applyMode || targets.length === 0) return; for (const target of targets) { const rowsToNeutralize = target.history.filter((row) => { const pnl = toNumber(row.pnl); if (Math.abs(pnl) <= EPS) return false; return !startsWithIgnoreCase(row.reason, ZEROED_PREFIX) && !startsWithIgnoreCase(row.reason, CANONICAL_REASON); }); for (const row of rowsToNeutralize) { const nextReason = startsWithIgnoreCase(row.reason, ZEROED_PREFIX) ? String(row.reason || '') : `${ZEROED_PREFIX} ${String(row.reason || 'Lifecycle row superseded by canonical order-derived aggregate').trim()}`; const { error } = await supabase .from('trade_history') .update({ pnl: 0, pnl_percent: 0, reason: nextReason }) .eq('id', row.id); if (error) throw error; } const canonicalPayload = { user_id: target.canonical.userId, profile_id: target.canonical.profileId, symbol: target.canonical.symbol, trade_id: target.canonical.tradeId, side: target.canonical.side, size: Number(target.canonical.closedQty.toFixed(8)), entry_price: Number(target.canonical.avgEntry.toFixed(8)), exit_price: Number(target.canonical.avgExit.toFixed(8)), pnl: Number(target.canonical.pnl.toFixed(8)), pnl_percent: Number(target.canonical.pnlPercent.toFixed(8)), reason: CANONICAL_REASON, source: 'BOT', timestamp: Date.now() }; const existingCanonical = target.history.find((row) => startsWithIgnoreCase(row.reason, CANONICAL_REASON)); if (existingCanonical) { const { error } = await supabase .from('trade_history') .update(canonicalPayload) .eq('id', existingCanonical.id); if (error) throw error; } else { const { error } = await supabase .from('trade_history') .insert([canonicalPayload]); if (error) throw error; } } }; run().catch((error) => { console.error('[reconcileTradeHistoryLifecycle] failed:', error); process.exit(1); });