diff --git a/oanda_daemon.py b/oanda_daemon.py index 65b02d1..82797d7 100644 --- a/oanda_daemon.py +++ b/oanda_daemon.py @@ -90,8 +90,17 @@ STATE_FILE = os.path.join( def _oanda_setting_int(env_key: str, json_key: str, default: int) -> int: env = os.environ.get(env_key) if env: - return int(env) - return int(get_app_config().oanda_settings.get(json_key, default)) + try: + return int(env) + except ValueError: + raise SystemExit( + f"{env_key}={env!r} is not an integer — refusing to start") + try: + return int(get_app_config().oanda_settings.get(json_key, default)) + except (TypeError, ValueError): + raise SystemExit( + f"config.json oanda.{json_key} is not an integer — " + f"refusing to start") def _oanda_setting_str(env_key: str, json_key: str, default: str) -> str: @@ -165,7 +174,13 @@ class TradingDaemon: base_url = oanda_base_url() lot_size = _oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1) self._quality_gate = _oanda_setting_str( - "OANDA_QUALITY_GATE", "quality_gate", "C") + "OANDA_QUALITY_GATE", "quality_gate", "C").strip() + if self._quality_gate not in QUALITY_RANK: + # Fail CLOSED: an unrecognized gate must never silently fall + # back to the most permissive rank (QUALITY_RANK.get default). + raise SystemExit( + f"OANDA_QUALITY_GATE/quality_gate {self._quality_gate!r} " + f"is not one of {sorted(QUALITY_RANK)} — refusing to start") self._max_hold_bars = _oanda_setting_int( "OANDA_MAX_HOLD_BARS", "max_hold_bars", 300) self._price_precision = PAIR_INFO[SYMBOL]["price_precision"] @@ -275,60 +290,86 @@ class TradingDaemon: # ── Position discovery (restart recovery) ────────────────────── def _discover_existing_positions(self) -> None: + self._reconcile_positions("boot discovery") + + def _reconcile_positions(self, reason: str) -> None: + """Re-sync tracked state with the broker's open trades. Safe to + call mid-run (lock-guarded); used at boot and whenever a fill or + placement error suggests state and broker may have diverged.""" try: open_trades = self._executor.get_open_trades(INSTRUMENT) except OandaAPIError: - logger.exception("Failed to query open trades at boot") + logger.exception( + "Failed to query open trades (%s) — reconcile skipped", + reason) return - saved = self._state.get("trades", {}) live_ids = {t["id"] for t in open_trades} - stale = [tid for tid in saved if tid not in live_ids] - for tid in stale: - logger.info( - "Stale trade %s in state file (closed on Oanda), removing", tid) - del saved[tid] + with self._state_lock: + saved = self._state.get("trades", {}) - for trade in open_trades: - tid = trade["id"] - if tid in saved: + stale = [tid for tid in saved if tid not in live_ids] + for tid in stale: logger.info( - "Recovered trade %s from state: %s %s @ %.2f, SL %.2f", - tid, saved[tid].get("direction"), INSTRUMENT, - saved[tid].get("entry"), saved[tid].get("current_sl")) - continue + "Stale trade %s in state file (closed on Oanda), " + "removing", tid) + del saved[tid] - direction = "BUY" if float(trade.get("currentUnits", 0)) > 0 else "SELL" - entry = float(trade.get("price", 0)) - sl_order = trade.get("stopLossOrder", {}) - sl = float(sl_order.get("price", entry)) - risk = abs(entry - sl) if sl else 1.0 + for trade in open_trades: + tid = trade["id"] + if tid in saved: + logger.info( + "Recovered trade %s from state: %s %s @ %.2f, SL %.2f", + tid, saved[tid].get("direction"), INSTRUMENT, + saved[tid].get("entry"), saved[tid].get("current_sl")) + continue - saved[tid] = { - "tf": "H1", - "direction": direction, - "entry": entry, - "sl_initial": sl, - "current_sl": sl, - "risk": risk, - "peak": entry, - "trail_armed": False, - "trail_arm_r": 0.6, - "trail_dist_r": 0.75, - "bars_held": 0, - "last_bar_ts": None, - "signal_bar_ts": None, - } - logger.info( - "Adopted unknown trade %s: %s @ %.2f, SL %.2f, risk %.2f " - "(conservative defaults)", - tid, direction, entry, sl, risk) + direction = ("BUY" if float(trade.get("currentUnits", 0)) > 0 + else "SELL") + entry = float(trade.get("price", 0)) + sl_order = trade.get("stopLossOrder") or {} + sl_price = sl_order.get("price") + if sl_price is None: + # No broker-side stop at all: risk is undefined, so the + # trailing model cannot manage this trade. Scream — a + # naked position needs a human (or a policy) to set a + # stop; fabricating one here would invent trading + # behavior the books never validated. + logger.critical( + "Adopted trade %s (%s @ %.2f) has NO STOP LOSS on " + "the broker — it will NOT be trailed. Set a stop " + "manually or close it.", tid, direction, entry) + sl = entry + risk = 0.0 + else: + sl = float(sl_price) + risk = abs(entry - sl) - self._state["trades"] = saved - _save_state(self._state) + saved[tid] = { + "tf": "H1", + "direction": direction, + "entry": entry, + "sl_initial": sl, + "current_sl": sl, + "risk": risk, + "peak": entry, + "trail_armed": False, + "trail_arm_r": 0.6, + "trail_dist_r": 0.75, + "bars_held": 0, + "last_bar_ts": None, + "signal_bar_ts": None, + } + logger.info( + "Adopted unknown trade %s: %s @ %.2f, SL %.2f, risk %.2f " + "(conservative defaults)", + tid, direction, entry, sl, risk) + + self._state["trades"] = saved + _save_state(self._state) logger.info( - "Position discovery: %d active trade(s)", len(saved)) + "Position reconcile (%s): %d active trade(s)", reason, len(saved)) # ── Candle-boundary loop ─────────────────────────────────────── @@ -521,9 +562,15 @@ class TradingDaemon: stop_loss=ts.stop_loss, ) except OandaAPIError: + # The order is never retried (a timeout after a server-side + # fill would double-up on retry), but the server MAY have + # filled before the error reached us — reconcile so a live + # position can't stay untracked until the next restart. logger.exception( - "Failed to place %s order for %s %s", + "Failed to place %s order for %s %s — reconciling " + "broker positions", ts.direction, SYMBOL, tf.value) + self._reconcile_positions("order placement error") return fill_tx = result.get("orderFillTransaction") @@ -535,7 +582,16 @@ class TradingDaemon: trade_opened = fill_tx.get("tradeOpened", {}) trade_id = trade_opened.get("tradeID") if not trade_id: - logger.warning("No trade ID in fill response") + # A fill with no tradeOpened means the order interacted + # with an existing broker position (tradesClosed / + # tradeReduced on a netting account) — state and broker + # have diverged. Surface it and re-sync instead of + # silently forgetting a real fill. + logger.critical( + "Order FILLED but opened no trade (netting close/" + "reduce?) — fill: closed=%s reduced=%s. Reconciling.", + fill_tx.get("tradesClosed"), fill_tx.get("tradeReduced")) + self._reconcile_positions("fill without tradeOpened") return fill_price = float(fill_tx.get("price", ts.entry_price)) @@ -587,8 +643,12 @@ class TradingDaemon: # ── Trailing stop management ─────────────────────────────────── def _manage_trailing(self, tf: Timeframe) -> None: + # Copy the per-trade dicts too: the loop mutates info (peak, + # bars_held, last_bar_ts) and must not touch live state outside + # the lock — another thread may be serializing it in _save_state. with self._state_lock: - trades = dict(self._state.get("trades", {})) + trades = {tid: dict(info) + for tid, info in self._state.get("trades", {}).items()} for trade_id, info in trades.items(): if info.get("tf") != tf.value: @@ -634,13 +694,16 @@ class TradingDaemon: self._executor.close_trade(trade_id) except OandaAPIError: # Keep tracking — the trade is still live on the - # broker; we retry the close at the next bar. + # broker (incl. a 200 whose close order was cancelled, + # e.g. MARKET_HALTED — close_trade raises on no-fill); + # we retry the close at the next bar. logger.exception( "Failed to close timed-out trade %s — will retry", trade_id) with self._state_lock: - self._state["trades"][trade_id] = info - _save_state(self._state) + if trade_id in self._state["trades"]: + self._state["trades"][trade_id] = info + _save_state(self._state) continue with self._state_lock: self._state["trades"].pop(trade_id, None) @@ -651,6 +714,14 @@ class TradingDaemon: bar_high = float(last_bar["High"]) bar_low = float(last_bar["Low"]) + if info.get("risk", 0) <= 0: + # Adopted naked position (no broker SL → risk undefined): + # never manageable by the trailing model — keep it loud + # instead of silently skipping forever. + logger.warning( + "Trade %s has risk<=0 (no stop loss) — trailing " + "skipped; set a stop manually", trade_id) + new_sl = self._compute_trail(info, bar_high, bar_low) if new_sl is not None: @@ -667,8 +738,13 @@ class TradingDaemon: trade_id) with self._state_lock: - self._state["trades"][trade_id] = info - _save_state(self._state) + # Write back ONLY if the trade is still tracked — another + # loop's _check_closed_trades may have popped it while we + # iterated (broker-side close). Re-inserting would create + # a ghost that blocks new entries. + if trade_id in self._state["trades"]: + self._state["trades"][trade_id] = info + _save_state(self._state) def _compute_trail( self, info: Dict, bar_high: float, bar_low: float diff --git a/oanda_executor.py b/oanda_executor.py index 5a1a2d0..f5eba31 100644 --- a/oanda_executor.py +++ b/oanda_executor.py @@ -69,6 +69,13 @@ class OandaExecutor: "instrument": instrument, "units": signed_units, "timeInForce": "FOK", + # The book model is one position total; on a netting + # account an opposite-side order would silently CLOSE an + # existing (possibly untracked) position instead of opening + # the signal trade. OPEN_ONLY makes that case fail loudly + # (order cancelled) so the divergence surfaces instead of + # executing an unintended close. + "positionFill": "OPEN_ONLY", "stopLossOnFill": { "price": self._fmt_price(stop_loss), }, @@ -156,7 +163,18 @@ class OandaExecutor: f"/trades/{trade_id}/close") resp = self._request_with_retry("PUT", url, json={"units": "ALL"}) data = self._parse_json(resp) - close_tx = data.get("orderFillTransaction", {}) + close_tx = data.get("orderFillTransaction") + if not close_tx: + # A 200 whose close order was created but NOT filled (e.g. + # cancelled with MARKET_HALTED during the settlement pause) + # means the position is STILL OPEN. Raising keeps the caller + # tracking it and retrying — silently returning here would + # orphan a live position. + reason = data.get("orderCancelTransaction", {}).get( + "reason", "no orderFillTransaction in response") + raise OandaAPIError( + f"Close of trade {trade_id} did not fill: {reason}", + resp.status_code, str(data)[:300]) close_price = float(close_tx.get("price", 0)) pl = float(close_tx.get("pl", 0)) logger.info( diff --git a/test_all.py b/test_all.py index cd5c963..fe29255 100644 --- a/test_all.py +++ b/test_all.py @@ -5298,6 +5298,233 @@ def _(): assert dpub.CONFIRMATIONS[Timeframe.H4] == [Timeframe.H1, Timeframe.D1] +# ══════════════════════════════════════════════════════════════════════ +# MODULE 21: 2026-08-04 audit-fix regressions (execution lifecycle) +# ══════════════════════════════════════════════════════════════════════ +print("\n═══ Module 21: 2026-08-04 audit fixes — execution lifecycle ═══") + +import threading as _thr21 + + +def _m21_daemon(state=None, quality_gate="C", max_hold=300): + d = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon) + d._state = state if state is not None else {"trades": {}, "last_entries": {}} + d._state_lock = _thr21.Lock() + d._entry_lock = _thr21.Lock() + d._quality_gate = quality_gate + d._max_hold_bars = max_hold + d._price_precision = 2 + d._running = True + return d + + +def _m21_bar(ts="2026-08-04 10:00:00+00:00", high=2410.0, low=2395.0): + idx = pd.DatetimeIndex([pd.Timestamp(ts)]) + return pd.DataFrame({"Open": [2400.0], "High": [high], "Low": [low], + "Close": [2405.0], "Volume": [1.0]}, index=idx) + + +def _m21_trade(**over): + base = {"tf": "5min", "direction": "BUY", "entry": 2400.0, + "sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0, + "peak": 2400.0, "trail_armed": False, "trail_arm_r": 0.6, + "trail_dist_r": 0.85, "bars_held": 0, + "signal_bar_ts": None, "last_bar_ts": None} + base.update(over) + return base + + +@test("executor: close_trade raises when the close order did not fill") +def _(): + ex = oanda_executor.OandaExecutor.__new__(oanda_executor.OandaExecutor) + ex._account_id, ex._base_url, ex._price_precision = "acc", "http://x", 2 + + class _Resp: + status_code = 200 + text = "{}" + def json(self): + return {"orderCreateTransaction": {"id": "1"}, + "orderCancelTransaction": {"reason": "MARKET_HALTED"}} + + ex._request_with_retry = lambda *a, **k: _Resp() + try: + ex.close_trade("T1") + assert False, "close_trade must raise on a no-fill close response" + except oanda_executor.OandaAPIError as e: + assert "MARKET_HALTED" in str(e) + + +@test("daemon: timeout close that did not fill keeps the trade tracked") +def _(): + d = _m21_daemon({"trades": {"T1": _m21_trade(bars_held=299)}, + "last_entries": {}}, max_hold=300) + + class _Exec: + def close_trade(self, tid): + raise oanda_executor.OandaAPIError( + "Close of trade T1 did not fill: MARKET_HALTED", 200, "") + + class _Prov: + def fetch_ohlcv(self, sym, tf): + return _m21_bar() + + d._executor, d._provider = _Exec(), _Prov() + d._manage_trailing(Timeframe.M5) + assert "T1" in d._state["trades"], \ + "un-filled close must NOT pop the trade from state" + + +@test("daemon: fill without tradeOpened triggers reconciliation, not silence") +def _(): + d = _m21_daemon() + recon = [] + d._reconcile_positions = lambda reason: recon.append(reason) + + class _Exec: + def place_market_order(self, **k): + return {"orderFillTransaction": { + "id": "5001", "price": "2400.5", + "tradesClosed": [{"tradeID": "OLD1"}]}} + + d._executor = _Exec() + ts = TradeSetup(pair="XAU_USD", display_name="XAU/USD", + direction="SELL", entry_price=2400.0, stop_loss=2416.0, + take_profit_1=2384.0, take_profit_2=2376.0, + risk_reward=1.0, risk_pips=160.0, reward_pips=160.0, + pip_value=0.1) + rep = SignalReport(symbol="XAU_USD", display_name="XAU/USD", + timestamp="2026-08-04 09:55:00+00:00") + rep.trade_setup, rep.signal_quality = ts, "B" + rep.final_signal = SignalStrength.SELL + d._execute_trade(rep, Timeframe.M5) + assert recon, "unexpected fill shape must trigger _reconcile_positions" + assert not d._state["trades"] + + +@test("daemon: placement OandaAPIError triggers reconciliation") +def _(): + d = _m21_daemon() + recon = [] + d._reconcile_positions = lambda reason: recon.append(reason) + + class _Exec: + def place_market_order(self, **k): + raise oanda_executor.OandaAPIError("timeout after retries") + + d._executor = _Exec() + ts = TradeSetup(pair="XAU_USD", display_name="XAU/USD", + direction="BUY", entry_price=2400.0, stop_loss=2384.0, + take_profit_1=2416.0, take_profit_2=2432.0, + risk_reward=1.0, risk_pips=160.0, reward_pips=160.0, + pip_value=0.1) + rep = SignalReport(symbol="XAU_USD", display_name="XAU/USD", + timestamp="2026-08-04 09:55:00+00:00") + rep.trade_setup, rep.signal_quality = ts, "B" + rep.final_signal = SignalStrength.BUY + d._execute_trade(rep, Timeframe.M5) + assert recon, "placement error must trigger _reconcile_positions " \ + "(server may have filled before the error)" + + +@test("daemon: reconcile adopts naked position with risk=0 and no fake SL") +def _(): + d = _m21_daemon() + + class _Exec: + def get_open_trades(self, instrument=None): + return [{"id": "N1", "instrument": "XAU_USD", + "currentUnits": "1", "price": "2400.0"}] # no stopLossOrder + + d._executor = _Exec() + d._reconcile_positions("test") + info = d._state["trades"]["N1"] + assert info["risk"] == 0.0, "risk must be 0 (undefined), not entry-diff" + assert d._compute_trail(dict(info), 2500.0, 2400.0) is None, \ + "risk=0 must never arm the trail (no invented stop)" + + +@test("daemon: trade popped mid-trailing is NOT resurrected by write-back") +def _(): + d = _m21_daemon({"trades": {"R1": _m21_trade()}, "last_entries": {}}) + + class _Exec: + def get_open_trades(self, instrument=None): + return [] + def modify_trade_sl(self, tid, sl): + return {} + + d._executor = _Exec() + + class _Prov: + def fetch_ohlcv(self, sym, tf): + d._check_closed_trades() # interleave: broker closed R1 + assert "R1" not in d._state["trades"] + return _m21_bar() + + d._provider = _Prov() + d._manage_trailing(Timeframe.M5) + assert "R1" not in d._state["trades"], \ + "write-back must not re-insert a trade closed by the broker" + + +@test("daemon: trailing snapshot no longer aliases live state dicts") +def _(): + d = _m21_daemon({"trades": {"S1": _m21_trade()}, "last_entries": {}}) + with d._state_lock: + snap = {tid: dict(info) + for tid, info in d._state.get("trades", {}).items()} + assert snap["S1"] is not d._state["trades"]["S1"] + + +@test("daemon: unknown quality gate refuses to start (fail closed)") +def _(): + real_init = oanda_daemon.TradingDaemon.__init__ + # Drive just the validation logic the ctor now applies. + gate = "Bb" + assert gate not in oanda_daemon.QUALITY_RANK + d = _m21_daemon(quality_gate="Bb") + # _should_execute is unreachable in prod with a bad gate (ctor exits); + # the ctor check is the contract: + src = open(os.path.join(os.path.dirname(__file__), + "oanda_daemon.py"), encoding="utf-8").read() + assert "refusing to start" in src and "not in QUALITY_RANK" in src + + +@test("executor: market orders request positionFill=OPEN_ONLY") +def _(): + ex = oanda_executor.OandaExecutor.__new__(oanda_executor.OandaExecutor) + ex._account_id, ex._base_url, ex._price_precision = "acc", "http://x", 2 + ex._lot_size = 1 + sent = {} + + class _Resp: + status_code = 200 + text = "{}" + def json(self): + return {"orderFillTransaction": { + "price": "2400.0", "tradeOpened": {"tradeID": "T9"}}} + + def _req(method, url, retries=None, **kw): + sent.update(kw.get("json", {})) + return _Resp() + + ex._request_with_retry = _req + ex.place_market_order("XAU_USD", "BUY", stop_loss=2384.0) + assert sent["order"]["positionFill"] == "OPEN_ONLY" + + +@test("daemon: _oanda_setting_int rejects a non-integer env value cleanly") +def _(): + os.environ["OANDA_LOT_SIZE"] = "one" + try: + oanda_daemon._oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1) + assert False, "must SystemExit on a non-integer env value" + except SystemExit as e: + assert "not an integer" in str(e) + finally: + os.environ.pop("OANDA_LOT_SIZE", None) + + # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════