Skip to content

feat(paper): synthetic account + Rail 11 drawdown enforcement + sizing fix - #136

Merged
eaitbrahim merged 12 commits into
mainfrom
feat/paper-mode-fidelity
Jul 23, 2026
Merged

feat(paper): synthetic account + Rail 11 drawdown enforcement + sizing fix#136
eaitbrahim merged 12 commits into
mainfrom
feat/paper-mode-fidelity

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Paper-mode fidelity: synthetic account + Rail 11 enforcement + sizing fix

Makes paper trading a faithful rehearsal of live so the paper-forward produces a trustworthy out-of-sample track record for the promotion gate.

Spec: docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md
Plan: docs/superpowers/plans/2026-07-23-paper-mode-fidelity.md

What & why

Two coupled defects in the paper path, fixed together (Rail 11 needs an equity denominator, which needs real sizing, which needs per-position qty):

  1. Rail 11 (the drawdown circuit breaker) was inert in paper — the agent hard-set equity_now = None in paper mode, so update_drawdown never ran and the breaker read a frozen 0. A catastrophic drawdown could run a paper strategy into the ground uncapped, polluting the OOS record.
  2. Paper mis-sized its fills — it built a risk-sized intent only to gate the guard check, then filled a fixed 1 unit, with no cash balance. Drawdown-as-a-percentage was undefined.

How

A synthetic paper account (cash + qty-bearing positions, persisted in agent_state) is seeded once from real broker mark-to-market equity (fallback paper.starting_equity_usd), marked to market each cycle, and fed into the existing equity.update_drawdown producer — which writes the same global scalars guards.py's Rail 11 already reads. guards.py and the DB schema are unchanged. Fills are sized off account equity via _build_intent(equity_override=...) (live path unchanged). Mode-flip clears the shared HWM so a synthetic HWM can't poison live equity. Halt = veto new buys; open positions ride to their stops (mirrors live).

Design decisions

  • Paper sizes off its synthetic account equity (like the sim), not the $5k max_exposure proxy.
  • Seeded once from real equity at start; loop is broker-free thereafter.
  • Observability via LoopResult fields + _print_loop_result + an agent.paper_equity log event (a dedicated keel status command is deferred).

Testing

1447 tests pass, ruff check clean. Includes an end-to-end acceptance test: a paper account driven to −20% / −8% through the real run_once loop gets its buys vetoed by Rail 11.

Process

Built subagent-driven, 10 TDD tasks, each spec+quality reviewed. The final whole-branch review found and fixed a Critical the per-task reviews missed (an epoch cutoff that mixed bar-time and wall-clock, dropping first-cycle positions on rehydration and desyncing cash) — resolved with an id-based epoch and independently re-verified.

Deferred follow-ups (non-blocking)

  • Pre-live-arming prerequisite: the live-side mode-clear is asymmetric; a paper→live flip with an unreadable first cycle leaves stale paper scalars for one cycle (self-heals; live not yet armed). Gate the live clear on == "paper" and hoist before the broker read before arming live. TODO left in agent.py.
  • Non-goals (Phase-4): fix the live executor's $5k-proxy sizing to use real equity; unify SimAccount and the paper account.
  • Minor cleanups: dead paper_ledger_start_ts field, a few test-hygiene nits.

🤖 Generated with Claude Code

eaitbrahim and others added 12 commits July 23, 2026 17:55
…ion_usd)

Adds the paper-forward account model config block ahead of paper-mode
fidelity wiring: a fallback equity seed (0 = no fallback, primary seed is
live mark-to-market equity) plus an optional monthly contribution. No
behavior wired yet -- config field, parser, templates, and golden fixtures
only.
…toff

Gives PaperTrader a persisted synthetic cash balance (paper_cash_usdc),
a funding check in _enter that rejects a fill when cash is insufficient,
an equity() method built on a new shared keel.execution.equity.mark_positions
helper (cash + mark-to-market positions, cost-basis fallback on stale/missing
prices), and an epoch cutoff (paper_ledger_start_ts) so rehydration ignores
legacy pre-epoch orders written before the synthetic account existed.
…ual fill cost

Fix 1: add costed:bool to _OpenPaperPosition so the "was this position debited?"
decision is recorded at open time and read back (not re-evaluated) at close and
in equity(). Without this, a position opened while cash was unseeded, then
seeded before it closed, credited cash with no matching debit and inflated
equity() by marking an uncosted position -- both manufactured equity from
nothing. Rehydrated positions are always post-epoch (the ledger-start cutoff
already excludes anything earlier), so they're marked costed=True.

Fix 2: the funding check in _enter now gates on the actual debit
(entry_fill*qty + fee) instead of the coarser intent notional (entry*qty), so
cash can no longer go negative for a seed strictly between the two -- per
spec Sec 4.2's stated purpose for the check.
…lars in paper

Wires the synthetic paper account into run_once's equity block so Rail 11's
drawdown scalars advance during paper trading instead of being hard-set to
None. Adds _seed_paper_account_if_needed: stamps/clears the shared
equity_high_water_mark/drawdown_total_pct/drawdown_weekly_pct/equity_history
keys on a paper<->live mode flip, then seeds paper_cash_usdc once from real
mark-to-market equity, falling back to config.paper.starting_equity_usd.
The symmetric live-side stamp/clear only fires right before a successful
update_drawdown call, so an unreadable broker still leaves the previous
cycle's scalars untouched (test_run_once_skips_the_drawdown_update_when_the_
quote_balance_is_unreadable is unaffected).
_paper_enter now takes paper_equity and passes it as _build_intent's
equity_override, then fills the trader with intent.qty instead of a
fixed 1 unit. run_once captures the paper branch's equity_now into a
paper_equity local and skips paper entries for the cycle (logged) when
it is None, rather than sizing off an unknown equity.
…-rebased)

Adds an optional recurring deposit to the synthetic paper account: applied
once per UTC calendar month (guards._utc_month_bounds), tracked via new
state key paper_last_contribution_month, and rebased through
equity.record_external_flow so the deposit is never misread as a drawdown
recovery. Default is 0 (disabled).
Adds the P4 Task 8 acceptance tests: guards-level test_paper_drawdown_halt_vetoes_buys
/ test_paper_weekly_drawdown_halt_vetoes_buys (offline=True, equity_state_mode=paper)
and a full-loop test that drives a real drawdown through run_once (seed cash, open a
paper position, mark it far down) and proves the next paper entry attempt is vetoed by
account_dd_breaker_total -- confirming Tasks 5-7's wiring end-to-end.
…/logs

Add optional paper_equity/drawdown_total_pct/drawdown_weekly_pct fields to
LoopResult (None-defaulted, so existing constructions still compile), populate
them from repo state right after update_drawdown in the paper branch of
run_once, log them via agent.paper_equity, and print them in
_print_loop_result -- the paper-forward observability for Rail 11's drawdown
scalars (a dedicated `keel status` command is deferred).
…rred)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ring

- C1 (critical): _load_open_positions used paper_ledger_start_ts (wall-clock
  now_ts at seed) against an order's BAR timestamp, which always predates
  wall-clock time -- dropping any position opened during the seeding cycle on
  the next rehydration while its cash debit persisted. Switched the epoch
  cutoff to a new paper_ledger_start_order_id (max paper order id at first
  seed), stamped once by seed_cash and compared by id, not by clock.
- I1: added a loop-level test proving the drawdown breaker vetoes a paper
  ENTER through the real run_once path, not only via a direct _paper_enter
  call.
- Item 7: clarified _paper_enter's no-fill reason string to cover both the
  already-open and insufficient-synthetic-cash cases.
- Item 5: documented (comment only) the known pre-live-arming asymmetry in
  the live-side mode stamp/clear; no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit f185875 into main Jul 23, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/paper-mode-fidelity branch July 23, 2026 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant