import logger from '../utils/logger.js'; import { config } from '../config/index.js'; import { getTradeProfileCapital } from './profileRepository.js'; import { getCapitalLedger, upsertCapitalLedger } from './capitalLedgerRepository.js'; export interface CapitalLedgerRecord { profile_id: string; allocated_capital: number; reserved_for_orders: number; reserved_for_positions: number; realized_pnl: number; updated_at: string; } const toNumeric = (value: unknown): number => { const numeric = Number(value); if (!Number.isFinite(numeric)) return 0; return numeric; }; export class CapitalLedger { private locks = new Map>(); private async withLock(profileId: string, work: () => Promise): Promise { const key = profileId || 'global'; const previous = this.locks.get(key) ?? Promise.resolve(); const next = previous.then(() => work()).finally(() => { if (this.locks.get(key) === next) { this.locks.delete(key); } }); this.locks.set(key, next); return next; } private async ensureLedger(profileId: string, allocatedCapital?: number): Promise { try { const profileCapital = await getTradeProfileCapital(profileId); const allocation = toNumeric(allocatedCapital ?? profileCapital?.allocatedCapital ?? config.TOTAL_CAPITAL); const existing = await getCapitalLedger(profileId); const nextRecord: CapitalLedgerRecord = { profile_id: profileId, allocated_capital: allocation, reserved_for_orders: toNumeric(existing?.reserved_for_orders), reserved_for_positions: toNumeric(existing?.reserved_for_positions), realized_pnl: toNumeric(existing?.realized_pnl), updated_at: new Date().toISOString() }; return await upsertCapitalLedger(nextRecord); } catch (err: any) { if (this.isRpcNetworkFailure(err)) { logger.error(`[CapitalLedger] ensureLedger network failure for ${profileId}, aborting ledger mutation (fail-closed): ${err.message}`); return null; } logger.error(`[CapitalLedger] ensureLedger unexpected error: ${err.message}`); return null; } } private async mutate( profileId: string, fn: (current: CapitalLedgerRecord) => Promise ) { return this.withLock(profileId, async () => { const ledger = await this.ensureLedger(profileId); if (!ledger) return null; return fn(ledger); }); } public async getLedger(profileId: string): Promise { return getCapitalLedger(profileId); } public async reserveForOrder(profileId: string, amount: number): Promise { if (!profileId || amount <= 0) return false; const result = await this.mutate(profileId, async (ledger) => { const available = this.availableCapital(ledger); if (available + 1e-8 < amount) { return null; } return upsertCapitalLedger({ ...ledger, reserved_for_orders: toNumeric(ledger.reserved_for_orders) + amount, updated_at: new Date().toISOString() }); }); if (result) return true; const ledger = await this.getLedger(profileId); if (!ledger) return false; const available = this.availableCapital(ledger); if (available + 1e-8 >= amount) { logger.error( `[CapitalLedger] reserveForOrder parity mismatch for ${profileId}: RPC rejected amount=${amount.toFixed(8)} despite available=${available.toFixed(8)}. ` + `This usually indicates fn_reserve_for_order is stale and does not include realized_pnl in its gate.` ); } else { logger.warn( `[CapitalLedger] reserveForOrder rejected for ${profileId}: requested=${amount.toFixed(8)}, available=${available.toFixed(8)}.` ); } return false; } public async releaseOrderReservation(profileId: string, amount: number): Promise { if (!profileId || amount <= 0) return; await this.mutate(profileId, async (ledger) => { return upsertCapitalLedger({ ...ledger, reserved_for_orders: Math.max(0, toNumeric(ledger.reserved_for_orders) - amount), updated_at: new Date().toISOString() }); }); } public async adjustPositionReservation(profileId: string, delta: number): Promise { if (!profileId || delta === 0) return; await this.mutate(profileId, async (ledger) => { return upsertCapitalLedger({ ...ledger, reserved_for_positions: Math.max(0, toNumeric(ledger.reserved_for_positions) + delta), updated_at: new Date().toISOString() }); }); } public async recordRealizedPnl(profileId: string, delta: number): Promise { if (!profileId || delta === 0) return; await this.mutate(profileId, async (ledger) => { return upsertCapitalLedger({ ...ledger, realized_pnl: toNumeric(ledger.realized_pnl) + delta, updated_at: new Date().toISOString() }); }); } public async rebuildLedger(profileId: string, reservedOrders: number, reservedPositions: number): Promise { if (!profileId) return; await this.withLock(profileId, async () => { const ledger = await this.ensureLedger(profileId); if (!ledger) return null; return upsertCapitalLedger({ ...ledger, reserved_for_orders: Math.max(0, toNumeric(reservedOrders)), reserved_for_positions: Math.max(0, toNumeric(reservedPositions)), updated_at: new Date().toISOString() }); }); } public async getAvailableCapital(profileId: string): Promise { if (!profileId) return null; const ledger = await this.ensureLedger(profileId); if (!ledger) return null; return this.availableCapital(ledger); } public availableCapital(record: CapitalLedgerRecord) { return record.allocated_capital - record.reserved_for_orders - record.reserved_for_positions + record.realized_pnl; } private isRpcNetworkFailure(error: any): boolean { const message = String(error?.message || '').toLowerCase(); return message.includes('fetch failed') || message.includes('network'); } } export const capitalLedger = new CapitalLedger();