diff --git a/executor/main.py b/executor/main.py index df74759..4a7ec1e 100644 --- a/executor/main.py +++ b/executor/main.py @@ -1490,6 +1490,18 @@ def _conviction_rank(ticker_pos): predictions_date=predictions_date, ) n_entered = len(opt_entries) + if not opt_entries and not opt_exits: + _diag = (shadow_log or {}).get("diagnostics") or {} + logger.info( + "Optimizer solved %r with no rebalance trades " + "(turnover_one_way=%s below the trade threshold) — " + "the current portfolio already matches target. Order " + "book intentionally carries no optimizer entries/" + "exits today; existing positions are retained with " + "stops. This is a valid HOLD, not a fault.", + _diag.get("status"), + _diag.get("turnover_one_way"), + ) else: logger.error( "use_portfolio_optimizer=True but optimizer log is not " diff --git a/executor/optimizer_cutover.py b/executor/optimizer_cutover.py index 3d8e18a..788d289 100644 --- a/executor/optimizer_cutover.py +++ b/executor/optimizer_cutover.py @@ -41,6 +41,18 @@ def is_log_usable(log: dict | None) -> bool: Conservative gate: refuses anything but a clean ``optimal`` / ``optimal_inaccurate`` solve. Callers fall back to an empty order book on False (safer than wrong trades). + + An ``optimal``/``ok`` solve with an EMPTY ``would_be_trades`` is + *usable* — it is the optimizer's legitimate verdict that the current + portfolio already matches the target within the turnover threshold + ("hold" day). It is NOT a failure. Conflating it with a genuine + failure (None / non-ok / non-optimal diag) was a real bug: the + 2026-05-19 weekday rerun solved ``optimal`` with turnover 0.17% + (would_be_trades=[]) yet the planner logged a false + ``optimizer log is not usable … Operator must investigate`` ERROR + and framed a correct hold as a safety fallback. Emptiness is now the + caller's concern (apply → a safe no-op; log INFO, not ERROR), not a + usability signal. """ if not log: return False @@ -49,8 +61,6 @@ def is_log_usable(log: dict | None) -> bool: diag = log.get("diagnostics") or {} if diag.get("status") not in _OK_DIAG_STATUSES: return False - if not log.get("would_be_trades"): - return False return True diff --git a/tests/test_optimizer_cutover.py b/tests/test_optimizer_cutover.py index 882c884..820a85c 100644 --- a/tests/test_optimizer_cutover.py +++ b/tests/test_optimizer_cutover.py @@ -2,8 +2,10 @@ Unit tests for executor/optimizer_cutover.py — PR 5 of portfolio-optimizer arc. Covers: - - is_log_usable: happy + every failure mode (no log, sentinel, infeasible, - empty would_be_trades) + - is_log_usable: happy + every failure mode (no log, sentinel, infeasible). + An optimal/ok solve with EMPTY would_be_trades is a valid HOLD, not a + failure — it is usable (apply → safe no-op). Regression lock for the + 2026-05-19 conflation bug. - apply_optimizer_targets_to_orderbook: * BUY → entry record with optimizer-derived shares / dollars / triggers * SELL @ target=0 → urgent_exit EXIT for full held shares @@ -67,13 +69,50 @@ def test_is_log_usable_infeasible_diag(): assert is_log_usable(log) is False -def test_is_log_usable_empty_trades(): +def test_is_log_usable_optimal_with_no_trades_is_usable(): + """2026-05-19 regression: an optimal/ok solve with EMPTY + would_be_trades is the optimizer's valid 'current ≈ target, hold' + verdict — usable, NOT a failure. Previously conflated with genuine + failures, producing a false 'operator must investigate' ERROR.""" log = { "shadow_status": "ok", - "diagnostics": {"status": "optimal"}, + "diagnostics": {"status": "optimal", "turnover_one_way": 0.0017}, "would_be_trades": [], } - assert is_log_usable(log) is False + assert is_log_usable(log) is True + + +def test_is_log_usable_missing_would_be_trades_key_still_usable(): + """Absent (not just empty) would_be_trades on a clean solve is also a + valid hold — usability depends only on the solve status.""" + log = {"shadow_status": "ok", "diagnostics": {"status": "optimal"}} + assert is_log_usable(log) is True + + +def test_apply_optimizer_empty_trades_is_safe_noop(): + """The caller path for a hold day: usable log + empty would_be_trades + → zero entries/exits, no exception (planner logs INFO, not ERROR).""" + ob = OrderBook({"date": "2026-05-19", "entries": [], "urgent_exits": [], + "stops": [], "executed_today": []}) + entries, exits = apply_optimizer_targets_to_orderbook( + log={"shadow_status": "ok", + "diagnostics": {"status": "optimal"}, + "would_be_trades": []}, + ob=ob, + ibkr=MagicMock(), + current_positions={}, + price_histories={}, + atr_map={}, + strategy_config={}, + vwap_map={}, + signals_raw={"date": "2026-05-19", "signals": {}}, + predictions_by_ticker={}, + market_regime="neutral", + run_date="2026-05-19", + predictions_date="2026-05-19", + ) + assert entries == [] + assert exits == [] # ── apply_optimizer_targets_to_orderbook ───────────────────────────────────────