613 lines
23 KiB
TypeScript
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();
|