Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions keel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading