Fix naked-stop regression: speed cache made the guard strip live brackets

The get_positions TTL cache (added for the speed fix) silently broke the
naked-stop guard: _broker_held_symbols read 2.5s-stale positions, so a
just-filled entry wasn't seen as held, the guard thought it was unfilled, and
cancelled its bracket on a price dip -> NAKED position (APWC bled -12.8%
unprotected today). Fixes:
- client.get_positions(fresh=True) bypasses the cache; the guard uses it.
- entry_grace_seconds (default 5): never cancel an entry within N seconds of
  arming, so a marketable fill can confirm before the bracket could be stripped.
Regression test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-18 10:20:00 -04:00
1 parent 27b0748b71
commit 1cd82de6a8
5 files changed
+43 -4

No files matched your search

+11 -1
View File
@@ -96,6 +96,10 @@ class ExecutionSettings:
# cap the marketable limit so a breakout FILLS on a runner instead of
# resting forever at the trigger, while still bounding slippage.
trigger_slippage_pct: float = 0.004
# don't cancel an entry within this many seconds of arming — a marketable
# fill needs a moment to confirm at the broker; cancelling first strips its
# bracket (the naked-stop failure mode).
entry_grace_seconds: float = 5.0
# --- account-aware sizing (matters most on a small REAL account) -------
# one position's dollar value <= this fraction of equity, so a single trade
# can't exceed buying power (critical at $300: 3 positions must fit, so ~1/3).
@@ -137,6 +141,7 @@ class ExecutionSettings:
trigger_slippage_pct=float(
values.get("TRADING_TRIGGER_SLIP_PCT", "0.004")
),
entry_grace_seconds=float(values.get("TRADING_ENTRY_GRACE_SECONDS", "5")),
max_position_pct=float(values.get("TRADING_MAX_POSITION_PCT", "0.33")),
liquidity_max_volume_pct=float(
values.get("TRADING_LIQUIDITY_MAX_VOLUME_PCT", "0.0")
@@ -251,7 +256,7 @@ class TradingExecutionService:
if client is None or not hasattr(client, "get_positions"):
return None
try:
positions = client.get_positions() or []
positions = client.get_positions(fresh=True) or []
return {str(p.get("symbol")) for p in positions if p.get("symbol")}
except Exception as exc: # noqa: BLE001
logger.warning("broker positions unreadable in entry guard: %s", exc)
@@ -855,6 +860,11 @@ class TradingExecutionService:
if symbol in broker_held:
self._armed.pop(order_id, None)
continue
# grace window: a just-armed entry may still be filling — don't strip
# its bracket before the fill can confirm (belt to the fresh-positions
# check above; together they close the naked-stop race)
if (now - armed["armed_at"]).total_seconds() < self.settings.entry_grace_seconds:
continue
armed["checks"] += 1
reason = None