diff --git a/keel/agent.py b/keel/agent.py index 44fb0c1a..6bd1dd38 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -377,6 +377,23 @@ def _seed_paper_account_if_needed( paper_trader.seed_cash(seed, now_ts) +def _clear_live_mode_if_needed(repo: Repository) -> None: + """Symmetric to `_seed_paper_account_if_needed`'s mode clear, for the live side. + + On a paper->live flip, clear the shared HWM/history/drawdown scalars before this cycle's + update_drawdown, so a synthetic paper HWM never poisons live equity. Called unconditionally + at the top of the live branch -- BEFORE the broker-equity read -- so an unreadable broker on + the first live cycle can't skip the clear and let stale paper scalars survive an extra cycle. + Gated on the prior mode being "paper" so a fresh/continuing live run leaves real scalars intact. + """ + if repo.get_state("equity_state_mode") == "paper": + repo.set_state("equity_high_water_mark", None) + repo.set_state("drawdown_total_pct", Decimal("0")) + repo.set_state("drawdown_weekly_pct", Decimal("0")) + repo.set_state("equity_history", []) + repo.set_state("equity_state_mode", "live") + + def _paper_resolve_bars( trader: PaperTrader, product_id: str, @@ -768,6 +785,11 @@ def run_once( repo.set_state("paper_last_contribution_month", month_start) equity_now = paper_trader.equity(latest_price_by_product) else: + # Mirrors `_seed_paper_account_if_needed`'s clear: unconditional, at the top of the + # branch, BEFORE the broker-equity read, so an unreadable broker on the first live + # cycle after a paper->live flip can't skip the clear (see + # `_clear_live_mode_if_needed`'s docstring). + _clear_live_mode_if_needed(repo) equity_now = _mark_to_market_equity( repo, broker, products, latest_price_by_product, config.quote_currency ) @@ -786,22 +808,9 @@ def run_once( paper=paper_trader is not None, ) else: - # The symmetric live-side mode stamp/clear -- only right before a REAL update, so an - # unreadable broker (equity_now is None, handled above) never gets to zero out the - # previous cycle's scalars on the strength of a stamp alone. - # TODO(pre-live-arming): asymmetric with the paper-side clear above -- this guards on - # `!= "live"` (fires unless already live) rather than `== "paper"` (fires only on an - # actual paper->live flip). On a paper->live flip whose first live cycle reads an - # unreadable broker, `equity_now` is None, this whole branch is skipped, and stale - # paper drawdown scalars survive one extra cycle before self-healing on the next - # readable cycle. Fix before arming live execution: gate this clear on `== "paper"` - # and hoist it above the broker-equity read so it fires unconditionally on the flip. - if paper_trader is None and repo.get_state("equity_state_mode") != "live": - repo.set_state("equity_high_water_mark", None) - repo.set_state("drawdown_total_pct", Decimal("0")) - repo.set_state("drawdown_weekly_pct", Decimal("0")) - repo.set_state("equity_history", []) - repo.set_state("equity_state_mode", "live") + # The live-side mode clear now happens up-front in the live branch above (see + # `_clear_live_mode_if_needed`), symmetrically with the paper-side clear in + # `_seed_paper_account_if_needed`. Nothing left to stamp/clear here. equity_mod.update_drawdown(repo, equity=equity_now, now_ts=now_ts) if paper_trader is not None: diff --git a/tests/test_agent.py b/tests/test_agent.py index cda07134..634110b7 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -636,6 +636,32 @@ def get_accounts(self) -> list[dict[str, Any]]: assert repo.get_state("drawdown_total_pct") is None +def test_paper_to_live_flip_clears_stale_scalars_even_when_broker_unreadable( + repo: Repository, +) -> None: + """Pre-live-arming fix: a paper->live flip whose FIRST live cycle reads an unreadable broker + must still clear the stale paper drawdown scalars, not let them survive a cycle. Regression + for the asymmetric live-side mode clear (was gated inside the equity-readable branch).""" + repo.set_state("equity_state_mode", "paper") + repo.set_state("equity_high_water_mark", Decimal("999999")) + repo.set_state("drawdown_total_pct", Decimal("0.9")) + repo.set_state("drawdown_weekly_pct", Decimal("0.5")) + + class _BrokenAccountsBroker(FakeBroker): + def get_accounts(self) -> list[dict[str, Any]]: + raise RuntimeError("broker down") + + series = {(PRODUCT, Granularity.ONE_DAY): [_candle(1_000 + i * 86_400) for i in range(30)]} + broker = _BrokenAccountsBroker(series=series) + + run_once(broker, repo, _config(), now_ts=1_000 + 29 * 86_400) + + assert repo.get_state("equity_state_mode") == "live" + assert repo.get_state("equity_high_water_mark") is None + assert repo.get_state("drawdown_total_pct") == Decimal("0") + assert repo.get_state("drawdown_weekly_pct") == Decimal("0") + + def test_run_once_computes_a_real_equity_that_moves_rail_11(repo: Repository) -> None: """Pins the VALUE, not just the presence, of the scalars.