learning_ai_invt_trdg/backend/src/services/canonicalLifecycleService.ts

613 lines
23 KiB
TypeScript

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<string, CanonicalLifecycleAggregate>;
};
}
type BuildSnapshotInput = {
orders: FilledLifecycleOrderRow[];
profiles: CanonicalLifecycleProfileMeta[];
symbolPrices: Record<string, number>;
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<string, number>): Map<string, number> => {
const map = new Map<string, number>();
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<string, number>): 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<string, CanonicalLifecycleProfileMeta>();
for (const profile of input.profiles || []) {
const profileId = String(profile.id || '').trim();
if (!profileId) continue;
profileById.set(profileId, profile);
}
const grouped = new Map<string, FilledLifecycleOrderRow[]>();
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<string, CanonicalLifecycleAggregate> = {};
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();