learning_ai_invt_trdg/backend/CAPITAL_FLOW_VALIDATION.md

48 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.