From d20094400127a76b8af84fc2f68245bce4d56ae6 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 22 Jul 2026 19:40:42 -0400 Subject: [PATCH] fix(agent): report paper cycles as mode=paper, not mode=confirm `_effective_mode` returns the executor string "confirm" for any non-live config -- paper included -- because paper never reaches an executor. The loop summary and the `agent.mode_resolved` log event surfaced that raw value verbatim, so a paper cycle printed `mode=confirm`, telling the user a confirm-mode (live) run had happened when the cycle only ever hit the paper path and never touched the broker. Split "what the executor uses" from "what we report": `mode` still drives `executor.execute`/`_handle_exits` on the live path, while a new `reported_mode` ("paper" when the paper trader is active, else `mode`) drives the log event and `LoopResult.mode`. Confirm and autonomous runs are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- keel/agent.py | 10 ++++++++-- tests/test_agent.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/keel/agent.py b/keel/agent.py index a0c76937..19e3d4e1 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -658,7 +658,13 @@ def run_once( # PAPER path instead, which never touches the broker. Constructed once per cycle and # rehydrated from the orders table, so a per-cycle agent resumes its open positions. paper_trader = PaperTrader(repo) if config.auto_trade.mode == "paper" else None - log_event(logger, logging.INFO, "agent.mode_resolved", mode=mode) + # What this cycle actually DID, for the log line and `LoopResult`. `_effective_mode` + # returns the executor string `"confirm"` for any non-live config -- paper included -- + # so reporting it verbatim told the user a confirm-mode (live) run happened when the + # cycle only ever hit the paper path. `executor.execute`/`_handle_exits` still get the + # real executor `mode`; only what we surface to the user changes. + reported_mode = "paper" if paper_trader is not None else mode + log_event(logger, logging.INFO, "agent.mode_resolved", mode=reported_mode) max_age_sec = config.auto_trade.interval_sec * FEED_STALENESS_CYCLES finest = _finest_granularity(granularities) @@ -792,7 +798,7 @@ def run_once( ts=now_ts, skipped=False, skip_reason=None, - mode=mode, + mode=reported_mode, polled=polled, products=products, stale_products=stale_products, diff --git a/tests/test_agent.py b/tests/test_agent.py index 7b5e5dd9..75f1d792 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -387,6 +387,22 @@ def test_paper_mode_places_nothing_even_when_autonomous(repo): assert broker.place_calls == [], "paper mode must never reach the broker" +def test_paper_mode_is_reported_as_paper_not_confirm(repo): + """The loop summary must name the real operating mode. + + Paper routes to the paper path and never touches the broker, but `_effective_mode` returns + the executor string `"confirm"` for any non-live config -- so a paper cycle used to report + `mode=confirm`, telling the user a confirm-mode (live) run happened when nothing did. The + reported mode must say `"paper"`. + """ + broker = FakeBroker(series={(PRODUCT, Granularity.ONE_DAY): [_candle(0, "100")]}) + config = _config(auto_trade=AutoTradeConfig(mode="paper", interval_sec=50_000)) + + result = run_once(broker, repo, config, now_ts=90_000) + + assert result.mode == "paper" + + # -- run_once: EXIT wiring on a held position ---------------------------------------------------