import type { FilledLifecycleOrderRow } from './tradingPersistenceTypes.js'; export type CanonicalLifecycleState = 'OPEN' | 'PARTIAL_EXIT' | 'CLOSED' | 'ORPHAN_EXIT'; export type CanonicalSide = 'BUY' | 'SELL'; export interface CanonicalLifecycleProfileMeta { id: string; name: string; allocatedCapital: number; isActive: boolean; userId?: string; } export interface CanonicalLifecycleRow { id: string; profileId: string; profileName: string; tradeId: string; symbol: string; side: CanonicalSide; state: CanonicalLifecycleState; entryQty: number; exitQty: number; matchedQty: number; openQty: number; entryAvgPrice: number; exitAvgPrice: number; openEntryAvgPrice: number; openNotional: number; realizedPnl: number; realizedPnlPercent: number; unrealizedPnl: number; currentPrice: number; stopLoss?: number; takeProfit?: number; subTag?: string; hasSyntheticOrder: boolean; lastEventAt: number; } export interface CanonicalOpenPosition { id: string; profileId: string; profileName: string; tradeId: string; symbol: string; side: CanonicalSide; size: number; entryPrice: number; currentPrice: number; pnl: number; pnlPercent: number; stopLoss?: number; takeProfit?: number; subTag?: string; lastEventAt: number; } export interface CanonicalRealizedTrade { id: string; profileId: string; profileName: string; tradeId: string; symbol: string; side: CanonicalSide; size: number; entryPrice: number; exitPrice: number; pnl: number; pnlPercent: number; closedAt: number; state: CanonicalLifecycleState; subTag?: string; } export interface CanonicalLifecycleAggregate { profileId: string; profileName: string; allocatedCapital: number; isActive: boolean; openTrades: number; openNotional: number; realizedPnl: number; unrealizedPnl: number; netPnl: number; tradeCount: number; wins: number; winRate: number; lastClosedTradeAt: number; } export interface CanonicalLifecycleSnapshot { generatedAt: number; diagnostics: { orderRows: number; lifecycleRows: number; openPositions: number; realizedTrades: number; truncated: boolean; syntheticRealizedTradesExcluded?: number; syntheticRealizedPnlExcluded?: number; }; profiles: CanonicalLifecycleProfileMeta[]; lifecycleRows: CanonicalLifecycleRow[]; openPositions: CanonicalOpenPosition[]; realizedTrades: CanonicalRealizedTrade[]; aggregates: { total: { openTrades: number; openNotional: number; realizedPnl: number; unrealizedPnl: number; netPnl: number; tradeCount: number; wins: number; winRate: number; }; byProfile: Record; }; } type BuildSnapshotInput = { orders: FilledLifecycleOrderRow[]; profiles: CanonicalLifecycleProfileMeta[]; symbolPrices: Record; truncated?: boolean; }; type LifecycleLot = { qty: number; price: number; stopLoss?: number; takeProfit?: number; }; const EPSILON = 1e-8; const SYNTHETIC_ORDER_PREFIXES = ['BFILL-', 'MANOVR-', 'RECON-BF', 'RECON-', 'SYNC-']; const toNumber = (value: unknown): number => { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : 0; }; const normalizeStatus = (status?: string | null): string => { const normalized = String(status || '').trim().toLowerCase().replace(/-/g, '_'); return normalized; }; const normalizeSide = (side?: string | null): CanonicalSide => { const normalized = String(side || '').trim().toUpperCase(); return normalized === 'SELL' || normalized === 'SHORT' ? 'SELL' : 'BUY'; }; const normalizeAction = (action?: string | null): 'ENTRY' | 'EXIT' | undefined => { const normalized = String(action || '').trim().toUpperCase(); if (normalized === 'ENTRY' || normalized === 'EXIT') return normalized; return undefined; }; const parseTimestampCandidate = (value: unknown): number => { if (typeof value === 'number') { if (!Number.isFinite(value) || value <= 0) return 0; return value > 1_000_000_000_000 ? value : value * 1000; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return 0; if (/^\d+(\.\d+)?$/.test(trimmed)) { return parseTimestampCandidate(Number(trimmed)); } const parsed = Date.parse(trimmed); if (Number.isFinite(parsed) && parsed > 0) return parsed; } return 0; }; const normalizeTimestamp = (row: FilledLifecycleOrderRow): number => { // Backfill/synthetic rows can carry historical `filled_at` or stale `timestamp` // that predates the ENTRY for the same trade. Use the latest persisted event time // to preserve lifecycle causality inside each trade. const fromTimestamp = parseTimestampCandidate(row.timestamp); const fromCreatedAt = parseTimestampCandidate(row.created_at); const fromFilledAt = parseTimestampCandidate(row.filled_at); return Math.max(fromTimestamp, fromCreatedAt, fromFilledAt, 0); }; const normalizeSymbolKey = (symbol: string): string => { return String(symbol || '') .toUpperCase() .replace(/[-_/]/g, '') .replace(/USDT/g, 'USD') .trim(); }; const normalizeOrderQty = (row: FilledLifecycleOrderRow): number => { const qty = toNumber(row.qty); if (qty > EPSILON) return qty; return toNumber(row.quantity); }; const normalizeOrderPrice = (row: FilledLifecycleOrderRow): number => { return toNumber(row.price); }; const isSyntheticOrderId = (orderIdRaw: unknown): boolean => { const normalized = String(orderIdRaw || '').trim().toUpperCase(); if (!normalized) return false; return SYNTHETIC_ORDER_PREFIXES.some((prefix) => normalized.startsWith(prefix)); }; const isBotLifecycleOrder = (row: FilledLifecycleOrderRow): boolean => { const source = String(row.source || '').trim().toUpperCase(); if (source === 'MANUAL') return false; const status = normalizeStatus(row.status); return status === 'filled' || status === 'partially_filled'; }; const buildSymbolPriceMap = (symbolPrices: Record): Map => { const map = new Map(); for (const [rawSymbol, rawPrice] of Object.entries(symbolPrices || {})) { const price = toNumber(rawPrice); if (price <= EPSILON) continue; const key = normalizeSymbolKey(rawSymbol); if (!key) continue; map.set(key, price); } return map; }; const lookupCurrentPrice = (symbol: string, priceMap: Map): number => { const key = normalizeSymbolKey(symbol); if (!key) return 0; return toNumber(priceMap.get(key) || 0); }; export class CanonicalLifecycleService { buildSnapshot(input: BuildSnapshotInput): CanonicalLifecycleSnapshot { const profileById = new Map(); for (const profile of input.profiles || []) { const profileId = String(profile.id || '').trim(); if (!profileId) continue; profileById.set(profileId, profile); } const grouped = new Map(); for (const row of input.orders || []) { if (!isBotLifecycleOrder(row)) continue; const tradeId = String(row.trade_id || '').trim(); const profileId = String(row.profile_id || '').trim(); if (!tradeId || !profileId) continue; const qty = normalizeOrderQty(row); if (qty <= EPSILON) continue; const key = `${profileId}|${tradeId}`; const list = grouped.get(key) || []; list.push(row); grouped.set(key, list); } const priceMap = buildSymbolPriceMap(input.symbolPrices || {}); const lifecycleRows: CanonicalLifecycleRow[] = []; const openPositions: CanonicalOpenPosition[] = []; const realizedTrades: CanonicalRealizedTrade[] = []; let syntheticRealizedTradesExcluded = 0; let syntheticRealizedPnlExcluded = 0; for (const [key, rows] of grouped.entries()) { const sorted = [...rows].sort((left, right) => { const leftTs = normalizeTimestamp(left); const rightTs = normalizeTimestamp(right); if (leftTs !== rightTs) return leftTs - rightTs; const leftOrderId = String(left.order_id || left.id || ''); const rightOrderId = String(right.order_id || right.id || ''); return leftOrderId.localeCompare(rightOrderId); }); const [profileId, tradeId] = key.split('|'); const profileMeta = profileById.get(profileId); const profileName = String(profileMeta?.name || profileId || 'Unknown Profile'); let symbol = ''; let entrySide: CanonicalSide | null = null; let entryQty = 0; let exitQty = 0; let entryNotional = 0; let exitNotional = 0; let matchedQty = 0; let matchedEntryNotional = 0; let matchedExitNotional = 0; let realizedPnl = 0; let lastEventAt = 0; let subTag = ''; let hasSyntheticOrder = false; let fallbackStopLoss = 0; let fallbackTakeProfit = 0; const lots: LifecycleLot[] = []; for (const row of sorted) { const qty = normalizeOrderQty(row); if (qty <= EPSILON) continue; const side = normalizeSide(row.side); const price = normalizeOrderPrice(row); const action = normalizeAction(row.action) || (side === 'SELL' ? 'EXIT' : 'ENTRY'); const rowTs = normalizeTimestamp(row); lastEventAt = Math.max(lastEventAt, rowTs); if (!symbol) { symbol = String(row.symbol || '').trim(); } if (!entrySide && action === 'ENTRY') { entrySide = side; } if (!subTag) { const candidateTag = String(row.sub_tag || '').trim(); if (candidateTag) subTag = candidateTag; } if (!hasSyntheticOrder && isSyntheticOrderId(row.order_id || row.id)) { hasSyntheticOrder = true; } const stopLoss = toNumber(row.stop_loss); const takeProfit = toNumber(row.take_profit); if (stopLoss > EPSILON) fallbackStopLoss = stopLoss; if (takeProfit > EPSILON) fallbackTakeProfit = takeProfit; if (action === 'ENTRY') { const sideForEntry: CanonicalSide = entrySide ?? side; entrySide = sideForEntry; if (side !== sideForEntry) continue; entryQty += qty; if (price > EPSILON) entryNotional += qty * price; lots.push({ qty, price, stopLoss: stopLoss > EPSILON ? stopLoss : undefined, takeProfit: takeProfit > EPSILON ? takeProfit : undefined }); continue; } const expectedExitSide: CanonicalSide = (entrySide || 'BUY') === 'BUY' ? 'SELL' : 'BUY'; if (side !== expectedExitSide) continue; exitQty += qty; if (price > EPSILON) exitNotional += qty * price; let remaining = qty; while (remaining > EPSILON && lots.length > 0) { const lot = lots[0]; const matched = Math.min(remaining, lot.qty); if (matched <= EPSILON) break; lot.qty -= matched; remaining -= matched; matchedQty += matched; if (lot.price > EPSILON && price > EPSILON) { matchedEntryNotional += matched * lot.price; matchedExitNotional += matched * price; realizedPnl += (entrySide || 'BUY') === 'BUY' ? (price - lot.price) * matched : (lot.price - price) * matched; } if (lot.qty <= EPSILON) { lots.shift(); } } } const openQty = lots.reduce((sum, lot) => sum + Math.max(0, lot.qty), 0); const openNotional = lots.reduce((sum, lot) => ( lot.price > EPSILON ? sum + (lot.qty * lot.price) : sum ), 0); const entryAvgPrice = matchedQty > EPSILON && matchedEntryNotional > EPSILON ? matchedEntryNotional / matchedQty : (entryQty > EPSILON && entryNotional > EPSILON ? entryNotional / entryQty : 0); const exitAvgPrice = matchedQty > EPSILON && matchedExitNotional > EPSILON ? matchedExitNotional / matchedQty : (exitQty > EPSILON && exitNotional > EPSILON ? exitNotional / exitQty : 0); const openEntryAvgPrice = openQty > EPSILON && openNotional > EPSILON ? openNotional / openQty : 0; const side: CanonicalSide = entrySide || 'BUY'; const direction = side === 'SELL' ? -1 : 1; const currentPrice = lookupCurrentPrice(symbol, priceMap); const unrealizedPnl = openQty > EPSILON && openEntryAvgPrice > EPSILON && currentPrice > EPSILON ? (currentPrice - openEntryAvgPrice) * openQty * direction : 0; let state: CanonicalLifecycleState = 'CLOSED'; if (entryQty <= EPSILON && exitQty > EPSILON) { state = 'ORPHAN_EXIT'; } else if (openQty > EPSILON && matchedQty > EPSILON) { state = 'PARTIAL_EXIT'; } else if (openQty > EPSILON) { state = 'OPEN'; } else { state = 'CLOSED'; } const realizedPnlPercent = entryAvgPrice > EPSILON ? (((exitAvgPrice - entryAvgPrice) / entryAvgPrice) * 100 * direction) : 0; let stopLoss = fallbackStopLoss > EPSILON ? fallbackStopLoss : undefined; let takeProfit = fallbackTakeProfit > EPSILON ? fallbackTakeProfit : undefined; for (const lot of lots) { if (!stopLoss && lot.stopLoss && lot.stopLoss > EPSILON) stopLoss = lot.stopLoss; if (!takeProfit && lot.takeProfit && lot.takeProfit > EPSILON) takeProfit = lot.takeProfit; if (stopLoss && takeProfit) break; } const lifecycleRow: CanonicalLifecycleRow = { id: `life:${profileId}:${tradeId}`, profileId, profileName, tradeId, symbol, side, state, entryQty: Number(entryQty.toFixed(8)), exitQty: Number(exitQty.toFixed(8)), matchedQty: Number(matchedQty.toFixed(8)), openQty: Number(openQty.toFixed(8)), entryAvgPrice: Number(entryAvgPrice.toFixed(8)), exitAvgPrice: Number(exitAvgPrice.toFixed(8)), openEntryAvgPrice: Number(openEntryAvgPrice.toFixed(8)), openNotional: Number((openQty * openEntryAvgPrice).toFixed(8)), realizedPnl: Number(realizedPnl.toFixed(8)), realizedPnlPercent: Number(realizedPnlPercent.toFixed(4)), unrealizedPnl: Number(unrealizedPnl.toFixed(8)), currentPrice: Number(currentPrice.toFixed(8)), stopLoss, takeProfit, subTag: subTag || undefined, hasSyntheticOrder, lastEventAt }; lifecycleRows.push(lifecycleRow); if (openQty > EPSILON) { const pnlPercent = openEntryAvgPrice > EPSILON ? (((currentPrice - openEntryAvgPrice) * direction) / openEntryAvgPrice) * 100 : 0; openPositions.push({ id: `open:${profileId}:${tradeId}`, profileId, profileName, tradeId, symbol, side, size: Number(openQty.toFixed(8)), entryPrice: Number(openEntryAvgPrice.toFixed(8)), currentPrice: Number(currentPrice.toFixed(8)), pnl: Number(unrealizedPnl.toFixed(8)), pnlPercent: Number(pnlPercent.toFixed(4)), stopLoss, takeProfit, subTag: subTag || undefined, lastEventAt }); } if (matchedQty > EPSILON && !hasSyntheticOrder) { realizedTrades.push({ id: `realized:${profileId}:${tradeId}`, profileId, profileName, tradeId, symbol, side, size: Number(matchedQty.toFixed(8)), entryPrice: Number(entryAvgPrice.toFixed(8)), exitPrice: Number(exitAvgPrice.toFixed(8)), pnl: Number(realizedPnl.toFixed(8)), pnlPercent: Number(realizedPnlPercent.toFixed(4)), closedAt: lastEventAt, state, subTag: subTag || undefined }); } } lifecycleRows.sort((a, b) => b.lastEventAt - a.lastEventAt || a.id.localeCompare(b.id)); openPositions.sort((a, b) => b.lastEventAt - a.lastEventAt || a.id.localeCompare(b.id)); realizedTrades.sort((a, b) => b.closedAt - a.closedAt || a.id.localeCompare(b.id)); const byProfile: Record = {}; for (const profile of input.profiles || []) { const profileId = String(profile.id || '').trim(); if (!profileId) continue; byProfile[profileId] = { profileId, profileName: profile.name, allocatedCapital: Number(profile.allocatedCapital || 0), isActive: Boolean(profile.isActive), openTrades: 0, openNotional: 0, realizedPnl: 0, unrealizedPnl: 0, netPnl: 0, tradeCount: 0, wins: 0, winRate: 0, lastClosedTradeAt: 0 }; } for (const row of lifecycleRows) { const profileId = row.profileId; if (!byProfile[profileId]) { byProfile[profileId] = { profileId, profileName: row.profileName, allocatedCapital: 0, isActive: false, openTrades: 0, openNotional: 0, realizedPnl: 0, unrealizedPnl: 0, netPnl: 0, tradeCount: 0, wins: 0, winRate: 0, lastClosedTradeAt: 0 }; } const aggregate = byProfile[profileId]; aggregate.openNotional += row.openNotional; aggregate.unrealizedPnl += row.unrealizedPnl; if (!row.hasSyntheticOrder) { aggregate.realizedPnl += row.realizedPnl; } else if (row.matchedQty > EPSILON) { syntheticRealizedTradesExcluded += 1; syntheticRealizedPnlExcluded += row.realizedPnl; } if (row.openQty > EPSILON) aggregate.openTrades += 1; if (row.matchedQty > EPSILON && !row.hasSyntheticOrder) { aggregate.tradeCount += 1; if (row.realizedPnl > 0) aggregate.wins += 1; aggregate.lastClosedTradeAt = Math.max(aggregate.lastClosedTradeAt, row.lastEventAt); } } let totalOpenTrades = 0; let totalOpenNotional = 0; let totalRealizedPnl = 0; let totalUnrealizedPnl = 0; let totalTrades = 0; let totalWins = 0; for (const aggregate of Object.values(byProfile)) { aggregate.openNotional = Number(aggregate.openNotional.toFixed(8)); aggregate.realizedPnl = Number(aggregate.realizedPnl.toFixed(8)); aggregate.unrealizedPnl = Number(aggregate.unrealizedPnl.toFixed(8)); aggregate.netPnl = Number((aggregate.realizedPnl + aggregate.unrealizedPnl).toFixed(8)); aggregate.winRate = aggregate.tradeCount > 0 ? Number(((aggregate.wins / aggregate.tradeCount) * 100).toFixed(4)) : 0; totalOpenTrades += aggregate.openTrades; totalOpenNotional += aggregate.openNotional; totalRealizedPnl += aggregate.realizedPnl; totalUnrealizedPnl += aggregate.unrealizedPnl; totalTrades += aggregate.tradeCount; totalWins += aggregate.wins; } const totalNetPnl = totalRealizedPnl + totalUnrealizedPnl; return { generatedAt: Date.now(), diagnostics: { orderRows: input.orders.length, lifecycleRows: lifecycleRows.length, openPositions: openPositions.length, realizedTrades: realizedTrades.length, truncated: Boolean(input.truncated), syntheticRealizedTradesExcluded, syntheticRealizedPnlExcluded: Number(syntheticRealizedPnlExcluded.toFixed(8)) }, profiles: input.profiles, lifecycleRows, openPositions, realizedTrades, aggregates: { total: { openTrades: totalOpenTrades, openNotional: Number(totalOpenNotional.toFixed(8)), realizedPnl: Number(totalRealizedPnl.toFixed(8)), unrealizedPnl: Number(totalUnrealizedPnl.toFixed(8)), netPnl: Number(totalNetPnl.toFixed(8)), tradeCount: totalTrades, wins: totalWins, winRate: totalTrades > 0 ? Number(((totalWins / totalTrades) * 100).toFixed(4)) : 0 }, byProfile } }; } } export const canonicalLifecycleService = new CanonicalLifecycleService();