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:
Harwinder Sekhon
2026-08-04 19:46:37 -04:00
parent 3c9bb2f017
commit c9829a2a67
3 changed files with 374 additions and 53 deletions
+19 -1
View File
@@ -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(