174 lines
6.8 KiB
TypeScript
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();
|