import 'dotenv/config'; import { createHash, randomUUID } from 'crypto'; import { config, loadDynamicConfig } from '../src/config/index.js'; import { normalizeOrderAction, normalizeTradeSide } from '../src/domain/tradingEnums.js'; import { healthTracker } from '../src/services/healthTracker.js'; import { ReconciliationBackfillAuditInsert, ReconciliationBackfillOrderInsert, supabaseService } from '../src/services/SupabaseService.js'; import { buildAlpacaSubTag } from '../src/utils/alpacaSubTag.js'; type CliOptions = { apply: boolean; tradeIds: string[]; }; type TradeSnapshot = { profileId: string; userId: string; tradeId: string; symbol: string; entrySide: 'BUY' | 'SELL'; entryQty: number; exitQty: number; openQty: number; entryAvgPrice: number; }; const EPSILON = 1e-8; const ORDER_ID_PREFIX = 'MANOVR'; const parseOptions = (argv: string[]): CliOptions => { const tradeIds = new Set(); let apply = false; for (const arg of argv) { if (arg === '--apply') { apply = true; continue; } if (arg.startsWith('--trade=')) { const value = String(arg.slice('--trade='.length) || '').trim(); if (value) tradeIds.add(value); } } return { apply, tradeIds: Array.from(tradeIds) }; }; const toNumber = (value: unknown): number => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }; const expectedExitSide = (entrySide: 'BUY' | 'SELL'): 'BUY' | 'SELL' => { return entrySide === 'BUY' ? 'SELL' : 'BUY'; }; const buildManualOverrideOrderId = (profileId: string, tradeId: string): string => { const digest = createHash('md5') .update(`${profileId}:${tradeId}:manual_override_v1`) .digest('hex'); return `${ORDER_ID_PREFIX}-${digest}`; }; const buildTradeSnapshots = (rows: any[]): Map => { const byTrade = new Map(); for (const row of rows || []) { const tradeId = String(row.trade_id || '').trim(); const profileId = String(row.profile_id || '').trim(); if (!tradeId || !profileId) continue; const key = `${profileId}::${tradeId}`; const qty = toNumber(row.qty ?? row.quantity); if (!(qty > EPSILON)) continue; const side = normalizeTradeSide(String(row.side || 'BUY')); const action = normalizeOrderAction(row.action || undefined); const symbol = String(row.symbol || '').trim(); const userId = String(row.user_id || '').trim(); const price = toNumber(row.price); let snapshot = byTrade.get(key); if (!snapshot) { snapshot = { profileId, userId, tradeId, symbol, entrySide: side, entryQty: 0, exitQty: 0, openQty: 0, entryAvgPrice: 0 }; byTrade.set(key, snapshot); } const resolvedAction = action || (side === snapshot.entrySide ? 'ENTRY' : 'EXIT'); if (resolvedAction === 'ENTRY') { if (!(snapshot.entryQty > EPSILON)) { snapshot.entrySide = side; } const nextQty = snapshot.entryQty + qty; snapshot.entryAvgPrice = nextQty > EPSILON ? ((snapshot.entryAvgPrice * snapshot.entryQty) + (price * qty)) / nextQty : snapshot.entryAvgPrice; snapshot.entryQty = nextQty; } else { snapshot.exitQty += qty; } if (!snapshot.symbol && symbol) snapshot.symbol = symbol; if (!snapshot.userId && userId) snapshot.userId = userId; snapshot.openQty = Number((snapshot.entryQty - snapshot.exitQty).toFixed(8)); } for (const [key, snapshot] of Array.from(byTrade.entries())) { if (!(snapshot.openQty > EPSILON)) { byTrade.delete(key); } } return byTrade; }; const run = async (): Promise => { const options = parseOptions(process.argv.slice(2)); if (options.tradeIds.length === 0) { throw new Error('Provide at least one --trade=.'); } await loadDynamicConfig(supabaseService); healthTracker.recordTradingControl({ mode: 'PAUSED', lastChangedBy: 'maintenance-script', lastChangedAt: Date.now(), reason: 'Manual override close cycle' }); const client = supabaseService.getClient(); if (!client) { throw new Error('Supabase client is not available.'); } const { data: lifecycleRows, error: lifecycleError } = await client .from('orders') .select('profile_id,user_id,trade_id,symbol,side,action,qty,quantity,price,status') .in('trade_id', options.tradeIds) .in('status', ['filled', 'partially_filled', 'partially-filled']); if (lifecycleError) { throw new Error(`Failed to fetch lifecycle rows: ${lifecycleError.message}`); } const snapshots = buildTradeSnapshots(lifecycleRows || []); const batchId = `MANOVR-BATCH-${randomUUID()}`; const nowIso = new Date().toISOString(); const nowTs = Date.now(); const candidateOrders: ReconciliationBackfillOrderInsert[] = []; const preAuditRows: ReconciliationBackfillAuditInsert[] = []; const skipped: Array> = []; for (const tradeId of options.tradeIds) { const matching = Array.from(snapshots.values()).filter((row) => row.tradeId === tradeId); if (matching.length === 0) { skipped.push({ tradeId, reason: 'trade_not_open_or_not_found' }); continue; } if (matching.length > 1) { skipped.push({ tradeId, reason: 'ambiguous_trade_multiple_profiles' }); continue; } const trade = matching[0]; if (!(trade.openQty > EPSILON)) { skipped.push({ tradeId, reason: 'trade_not_open' }); continue; } const orderId = buildManualOverrideOrderId(trade.profileId, trade.tradeId); const side = expectedExitSide(trade.entrySide); const subTag = buildAlpacaSubTag({ profileId: trade.profileId, tradeId: trade.tradeId, intent: 'EXIT' }) || undefined; const fillPrice = trade.entryAvgPrice > EPSILON ? trade.entryAvgPrice : 0; const order: ReconciliationBackfillOrderInsert = { user_id: trade.userId, profile_id: trade.profileId, order_id: orderId, symbol: trade.symbol, type: 'market', side, qty: Number(trade.openQty.toFixed(8)), quantity: Number(trade.openQty.toFixed(8)), price: Number(fillPrice.toFixed(8)), status: 'filled', timestamp: nowTs, filled_at: nowIso, trade_id: trade.tradeId, action: 'EXIT', source: 'BOT', sub_tag: subTag }; candidateOrders.push(order); preAuditRows.push({ batch_id: batchId, profile_id: trade.profileId, symbol: trade.symbol, trade_id: trade.tradeId, exchange_order_id: null, exchange_client_order_id: null, backfill_order_id: orderId, filled_qty: order.qty, filled_price: order.price, filled_at: order.filled_at || null, dry_run: !options.apply, decision: options.apply ? 'MANUAL_OVERRIDE_PENDING' : 'MANUAL_OVERRIDE_DRY', reason: 'manual_override_user_approved_no_exchange_evidence', metadata: { openQtyBefore: trade.openQty, entryQty: trade.entryQty, exitQty: trade.exitQty, fillPriceBasis: 'entry_weighted_avg_price' } }); } if (preAuditRows.length > 0) { const preSaved = await supabaseService.insertReconciliationBackfillAuditRows(preAuditRows); if (!preSaved) { throw new Error('Failed to save manual override pre-audit rows.'); } } let insertedRows = 0; if (options.apply && candidateOrders.length > 0) { const orderIds = candidateOrders.map((row) => row.order_id); const existingBefore = await supabaseService.getExistingOrderIds(orderIds); const ok = await supabaseService.upsertReconciliationBackfillOrders(candidateOrders); if (!ok) { throw new Error('Failed to apply manual override rows.'); } const existingAfter = await supabaseService.getExistingOrderIds(orderIds); insertedRows = candidateOrders.filter((row) => !existingBefore.has(row.order_id) && existingAfter.has(row.order_id)).length; const postAuditRows: ReconciliationBackfillAuditInsert[] = candidateOrders.map((row) => ({ batch_id: batchId, profile_id: row.profile_id, symbol: row.symbol, trade_id: row.trade_id, exchange_order_id: null, exchange_client_order_id: null, backfill_order_id: row.order_id, filled_qty: row.qty, filled_price: row.price, filled_at: row.filled_at || null, dry_run: false, decision: existingBefore.has(row.order_id) ? 'MANUAL_OVERRIDE_SKIP_EXISTING' : 'MANUAL_OVERRIDE_APPLIED', reason: existingBefore.has(row.order_id) ? 'already_exists' : 'manual_override_inserted', metadata: { matchedBy: 'manual_override' }, applied_at: !existingBefore.has(row.order_id) ? new Date().toISOString() : null })); const postSaved = await supabaseService.insertReconciliationBackfillAuditRows(postAuditRows); if (!postSaved) { throw new Error('Failed to save manual override post-audit rows.'); } } console.log(JSON.stringify({ mode: options.apply ? 'apply' : 'dry-run', batchId, requestedTrades: options.tradeIds, proposedRows: candidateOrders.length, insertedRows, skipped }, null, 2)); }; run().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(JSON.stringify({ error: message }, null, 2)); process.exit(1); });