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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
This commit is contained in:
+89
-13
@@ -90,8 +90,17 @@ STATE_FILE = os.path.join(
|
|||||||
def _oanda_setting_int(env_key: str, json_key: str, default: int) -> int:
|
def _oanda_setting_int(env_key: str, json_key: str, default: int) -> int:
|
||||||
env = os.environ.get(env_key)
|
env = os.environ.get(env_key)
|
||||||
if env:
|
if env:
|
||||||
|
try:
|
||||||
return int(env)
|
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))
|
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:
|
def _oanda_setting_str(env_key: str, json_key: str, default: str) -> str:
|
||||||
@@ -165,7 +174,13 @@ class TradingDaemon:
|
|||||||
base_url = oanda_base_url()
|
base_url = oanda_base_url()
|
||||||
lot_size = _oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1)
|
lot_size = _oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1)
|
||||||
self._quality_gate = _oanda_setting_str(
|
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(
|
self._max_hold_bars = _oanda_setting_int(
|
||||||
"OANDA_MAX_HOLD_BARS", "max_hold_bars", 300)
|
"OANDA_MAX_HOLD_BARS", "max_hold_bars", 300)
|
||||||
self._price_precision = PAIR_INFO[SYMBOL]["price_precision"]
|
self._price_precision = PAIR_INFO[SYMBOL]["price_precision"]
|
||||||
@@ -275,19 +290,30 @@ class TradingDaemon:
|
|||||||
# ── Position discovery (restart recovery) ──────────────────────
|
# ── Position discovery (restart recovery) ──────────────────────
|
||||||
|
|
||||||
def _discover_existing_positions(self) -> None:
|
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:
|
try:
|
||||||
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
||||||
except OandaAPIError:
|
except OandaAPIError:
|
||||||
logger.exception("Failed to query open trades at boot")
|
logger.exception(
|
||||||
|
"Failed to query open trades (%s) — reconcile skipped",
|
||||||
|
reason)
|
||||||
return
|
return
|
||||||
|
|
||||||
saved = self._state.get("trades", {})
|
|
||||||
live_ids = {t["id"] for t in open_trades}
|
live_ids = {t["id"] for t in open_trades}
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
saved = self._state.get("trades", {})
|
||||||
|
|
||||||
stale = [tid for tid in saved if tid not in live_ids]
|
stale = [tid for tid in saved if tid not in live_ids]
|
||||||
for tid in stale:
|
for tid in stale:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Stale trade %s in state file (closed on Oanda), removing", tid)
|
"Stale trade %s in state file (closed on Oanda), "
|
||||||
|
"removing", tid)
|
||||||
del saved[tid]
|
del saved[tid]
|
||||||
|
|
||||||
for trade in open_trades:
|
for trade in open_trades:
|
||||||
@@ -299,11 +325,26 @@ class TradingDaemon:
|
|||||||
saved[tid].get("entry"), saved[tid].get("current_sl"))
|
saved[tid].get("entry"), saved[tid].get("current_sl"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
direction = "BUY" if float(trade.get("currentUnits", 0)) > 0 else "SELL"
|
direction = ("BUY" if float(trade.get("currentUnits", 0)) > 0
|
||||||
|
else "SELL")
|
||||||
entry = float(trade.get("price", 0))
|
entry = float(trade.get("price", 0))
|
||||||
sl_order = trade.get("stopLossOrder", {})
|
sl_order = trade.get("stopLossOrder") or {}
|
||||||
sl = float(sl_order.get("price", entry))
|
sl_price = sl_order.get("price")
|
||||||
risk = abs(entry - sl) if sl else 1.0
|
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)
|
||||||
|
|
||||||
saved[tid] = {
|
saved[tid] = {
|
||||||
"tf": "H1",
|
"tf": "H1",
|
||||||
@@ -328,7 +369,7 @@ class TradingDaemon:
|
|||||||
self._state["trades"] = saved
|
self._state["trades"] = saved
|
||||||
_save_state(self._state)
|
_save_state(self._state)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Position discovery: %d active trade(s)", len(saved))
|
"Position reconcile (%s): %d active trade(s)", reason, len(saved))
|
||||||
|
|
||||||
# ── Candle-boundary loop ───────────────────────────────────────
|
# ── Candle-boundary loop ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -521,9 +562,15 @@ class TradingDaemon:
|
|||||||
stop_loss=ts.stop_loss,
|
stop_loss=ts.stop_loss,
|
||||||
)
|
)
|
||||||
except OandaAPIError:
|
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(
|
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)
|
ts.direction, SYMBOL, tf.value)
|
||||||
|
self._reconcile_positions("order placement error")
|
||||||
return
|
return
|
||||||
|
|
||||||
fill_tx = result.get("orderFillTransaction")
|
fill_tx = result.get("orderFillTransaction")
|
||||||
@@ -535,7 +582,16 @@ class TradingDaemon:
|
|||||||
trade_opened = fill_tx.get("tradeOpened", {})
|
trade_opened = fill_tx.get("tradeOpened", {})
|
||||||
trade_id = trade_opened.get("tradeID")
|
trade_id = trade_opened.get("tradeID")
|
||||||
if not trade_id:
|
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
|
return
|
||||||
|
|
||||||
fill_price = float(fill_tx.get("price", ts.entry_price))
|
fill_price = float(fill_tx.get("price", ts.entry_price))
|
||||||
@@ -587,8 +643,12 @@ class TradingDaemon:
|
|||||||
# ── Trailing stop management ───────────────────────────────────
|
# ── Trailing stop management ───────────────────────────────────
|
||||||
|
|
||||||
def _manage_trailing(self, tf: Timeframe) -> None:
|
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:
|
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():
|
for trade_id, info in trades.items():
|
||||||
if info.get("tf") != tf.value:
|
if info.get("tf") != tf.value:
|
||||||
@@ -634,11 +694,14 @@ class TradingDaemon:
|
|||||||
self._executor.close_trade(trade_id)
|
self._executor.close_trade(trade_id)
|
||||||
except OandaAPIError:
|
except OandaAPIError:
|
||||||
# Keep tracking — the trade is still live on the
|
# 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(
|
logger.exception(
|
||||||
"Failed to close timed-out trade %s — will retry",
|
"Failed to close timed-out trade %s — will retry",
|
||||||
trade_id)
|
trade_id)
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
|
if trade_id in self._state["trades"]:
|
||||||
self._state["trades"][trade_id] = info
|
self._state["trades"][trade_id] = info
|
||||||
_save_state(self._state)
|
_save_state(self._state)
|
||||||
continue
|
continue
|
||||||
@@ -651,6 +714,14 @@ class TradingDaemon:
|
|||||||
bar_high = float(last_bar["High"])
|
bar_high = float(last_bar["High"])
|
||||||
bar_low = float(last_bar["Low"])
|
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)
|
new_sl = self._compute_trail(info, bar_high, bar_low)
|
||||||
|
|
||||||
if new_sl is not None:
|
if new_sl is not None:
|
||||||
@@ -667,6 +738,11 @@ class TradingDaemon:
|
|||||||
trade_id)
|
trade_id)
|
||||||
|
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
|
# 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
|
self._state["trades"][trade_id] = info
|
||||||
_save_state(self._state)
|
_save_state(self._state)
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -69,6 +69,13 @@ class OandaExecutor:
|
|||||||
"instrument": instrument,
|
"instrument": instrument,
|
||||||
"units": signed_units,
|
"units": signed_units,
|
||||||
"timeInForce": "FOK",
|
"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": {
|
"stopLossOnFill": {
|
||||||
"price": self._fmt_price(stop_loss),
|
"price": self._fmt_price(stop_loss),
|
||||||
},
|
},
|
||||||
@@ -156,7 +163,18 @@ class OandaExecutor:
|
|||||||
f"/trades/{trade_id}/close")
|
f"/trades/{trade_id}/close")
|
||||||
resp = self._request_with_retry("PUT", url, json={"units": "ALL"})
|
resp = self._request_with_retry("PUT", url, json={"units": "ALL"})
|
||||||
data = self._parse_json(resp)
|
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))
|
close_price = float(close_tx.get("price", 0))
|
||||||
pl = float(close_tx.get("pl", 0))
|
pl = float(close_tx.get("pl", 0))
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+227
@@ -5298,6 +5298,233 @@ def _():
|
|||||||
assert dpub.CONFIRMATIONS[Timeframe.H4] == [Timeframe.H1, Timeframe.D1]
|
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
|
# SUMMARY
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user