audit: harden Oanda execution lifecycle (close-fill, netting, races, fail-closed config)
2026-08-04 Fable 5 QA audit, Batch A. All fixes are execution-robustness only — no signal code touched (git diff scope: daemon/executor/tests). Findings verified by runnable repros before fixing (h3 harness): - close_trade: a 200 whose close order was created but NOT filled (e.g. MARKET_HALTED during the settlement pause) now raises instead of returning; the daemon's timeout path keeps tracking and retries. The old path popped state and orphaned a live position forever (reconciliation was boot-only). - place_market_order: positionFill=OPEN_ONLY — on a netting account an opposite-side order can no longer silently close an untracked position; the divergence surfaces as an order cancel. - _execute_trade: a fill without tradeOpened (netting close/reduce) and any placement OandaAPIError (server may have filled before a network timeout) now trigger _reconcile_positions instead of silent return. - _discover_existing_positions → lock-guarded _reconcile_positions, callable mid-run; adoption of a position with NO broker stop now screams (CRITICAL + per-bar warning) instead of computing sl=entry, risk=0 and silently never trailing. - _manage_trailing: snapshot deep-copies per-trade dicts (no mutation of live state outside the lock); write-back skips trades popped by _check_closed_trades mid-iteration (no ghost resurrection). - Fail-closed config: unknown OANDA_QUALITY_GATE refuses to start (previously a typo silently became the most permissive gate "C"); non-integer OANDA_LOT_SIZE/MAX_HOLD_BARS exit with a clear message. Suite: 302 → 312 tests (Module 21), all green. Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
This commit is contained in:
+227
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user