5.2 KiB
5.2 KiB
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_ledgerswithallocated_capital(defaults toconfig.TOTAL_CAPITAL). available_capital = allocated_capital - reserved_for_orders - reserved_for_positions + realized_pnl(seeCapitalLedger.availableCapital).withLockuses 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 A’s 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 signal’s reservation and release before it runs.
Scenario 2: Simultaneous BUYs on two different profiles
- Profile X and Profile Y execute
openPositionnearly simultaneously. withLockuses profile-scoped keys, so each call manipulates its own ledger without waiting for the other.- Each signal calls
reserveForOrderindependently, and each ledger updates its ownreserved_for_orderscounter. - 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 slice’s 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
rebuildStartupStatereplays database/exchange state to recover pending orders and lifecycle data.- It calls
rebuildCapitalLedgerFromState, which:- Re-computes
reserved_for_ordersby summing thereservedAmountstored with every pending entry that still belongs to the profile. - Scans
config.SYMBOLSand reconstructsreserved_for_positionsfrom virtual open positions returned bySupabaseService.getVirtualOpenPosition.
- Re-computes
- The ledger RPC
fn_rebuild_ledgeroverwrites 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_positionsfrom 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 butcheck:trade-executor-lifecyclefailed because the hosted Supabase client could not reach the API (the ledger RPC layers throwTypeError: fetch failed, which causes the script’s assertion to fail while callingopenPosition). The remaining checks in the suite are automated, but their upstream Supabase dependency has to be re-established before that script succeeds.