learning_ai_invt_trdg/backend/src/services/CapitalLedger.ts

174 lines
6.8 KiB
TypeScript

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<string, Promise<unknown>>();
private async withLock<T>(profileId: string, work: () => Promise<T>): Promise<T | null> {
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<CapitalLedgerRecord | null> {
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<CapitalLedgerRecord | null>
) {
return this.withLock(profileId, async () => {
const ledger = await this.ensureLedger(profileId);
if (!ledger) return null;
return fn(ledger);
});
}
public async getLedger(profileId: string): Promise<CapitalLedgerRecord | null> {
return getCapitalLedger(profileId);
}
public async reserveForOrder(profileId: string, amount: number): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<number | null> {
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();