learning_ai_invt_trdg/backend/CAPITAL_FLOW_VALIDATION.md

5.2 KiB
Raw Blame History

Capital Flow Validation

This document records the capital ledger behavior across the new deterministic flows introduced in Phase 3. Each scenario references the exact hooks added to TradeExecutor and CapitalLedger so you can trace the state transitions.

Baseline assumptions

  • Every profile has an entry in capital_ledgers with allocated_capital (defaults to config.TOTAL_CAPITAL).
  • available_capital = allocated_capital - reserved_for_orders - reserved_for_positions + realized_pnl (see CapitalLedger.availableCapital).
  • withLock uses the profile ID as the key, preventing intra-profile overlaps while allowing multi-profile concurrency.

Scenario 1: Concurrent BUY signals for the same profile

Step Action Ledger snapshot Notes
0 Idle reserved_for_orders=0, reserved_for_positions=0, realized_pnl=0 Starting capital ready.
1 Signal A calls openPosition reserveForOrder runs because withLock obtains profileId, increments reserved_for_orders += estimate Reservation happens before sending the exchange order (TradeExecutor.openPosition, lines 416-432). If the ledger has insufficient capital, this call throws and the entry is rejected before duplicate logic runs.
2 Signal B arrives while A is holding the lock withLock queues Signal B; it cannot mutate the ledger until Signal A releases locks. If Signal A is still waiting, B waits on the same withLock promise; once the lock releases, B runs reserveForOrder against the updated ledger.
3 Signal A receives fill finalizeEntryReservation releases the reserved_for_orders that came from the pending order and immediately adds the notional to reserved_for_positions (filled cost) plus updates realized_pnl when the eventual exit runs.
4 Signal B runs after A completes Ledger now reflects As reserved_for_positions (plus any realized PnL). B can only reserve capital if allocated_capital - reserved_for_positions - reserved_for_orders still covers its estimate.

This workflow ensures two BUY signals for the same profile cannot both increase reserved_for_orders concurrently; the second signal always sees the ledger updated with the first signals reservation and release before it runs.

Scenario 2: Simultaneous BUYs on two different profiles

  1. Profile X and Profile Y execute openPosition nearly simultaneously.
  2. withLock uses profile-scoped keys, so each call manipulates its own ledger without waiting for the other.
  3. Each signal calls reserveForOrder independently, and each ledger updates its own reserved_for_orders counter.
  4. This means cross-profile capital isolation is enforced purely by the per-profile lock and the separate ledger rows (RLS also enforces row ownership in the schema). The system therefore tolerates multi-profile parallelism without leakage.

Scenario 3: Partial fill followed by remaining exit

Step Action Ledger update
1 Market entry fills partially (some qty) finalizeEntryReservation releases the reserved order amount and moves filledQty * fillPrice to reserved_for_positions. The remaining partial order is kept in pendingOrders until fully filled.
2 applyExitFill handles a partial exit slice adjustPositionReservation is called with a negative delta equal to the released slices notional, immediately freeing capital while realized_pnl records the actual gain/loss of the slice.
3 When the final close hits finalizeTrade applies the last release (reserved_for_positions -= entrySize * entryPrice) and records the complete realized_pnl, bringing reserved_for_positions back to zero and reflecting total profits/losses.

Scenario 4: Restart recovery

  1. rebuildStartupState replays database/exchange state to recover pending orders and lifecycle data.
  2. It calls rebuildCapitalLedgerFromState, which:
    • Re-computes reserved_for_orders by summing the reservedAmount stored with every pending entry that still belongs to the profile.
    • Scans config.SYMBOLS and reconstructs reserved_for_positions from virtual open positions returned by SupabaseService.getVirtualOpenPosition.
  3. The ledger RPC fn_rebuild_ledger overwrites the per-profile row with deterministic reserved amounts, so no stale reservations persist across restarts.

Validation notes

  • Concurrency locking is profile-scoped, so a script that fired two signals for the same profile would find the second call waiting for the first lock to release before it even attempts reserveForOrder.
  • Partial fills release and reassign capital immediately, preventing reserved_for_positions from drifting up past the actual exposure.
  • Restart recovery recomputes reservations from persisted state instead of relying on transient memory.

Test / Automation status

  • npm run check (build + lint + format) was executed but check:trade-executor-lifecycle failed because the hosted Supabase client could not reach the API (the ledger RPC layers throw TypeError: fetch failed, which causes the scripts assertion to fail while calling openPosition). The remaining checks in the suite are automated, but their upstream Supabase dependency has to be re-established before that script succeeds.