48 lines
5.2 KiB
Markdown
48 lines
5.2 KiB
Markdown
# 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 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
|
||
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 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
|
||
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 script’s 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.
|