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

+8 -1
View File
@@ -151,7 +151,14 @@ class AlpacaPaperClient:
def get_account(self) -> dict:
return self._cached("account", lambda: self._trading("GET", "/account"))
def get_positions(self) -> list[dict]:
def get_positions(self, fresh: bool = False) -> list[dict]:
# fresh=True bypasses the TTL cache — REQUIRED for the naked-stop guard,
# which must never decide an order is unfilled off a stale snapshot.
if fresh:
val = self._trading("GET", "/positions")
with self._cache_lock:
self._cache["positions"] = (val, time.monotonic())
return val
return self._cached("positions", lambda: self._trading("GET", "/positions"))
def get_orders(
+1 -1
View File
@@ -63,7 +63,7 @@ def test_close_session_flattens_and_blocks_new_entries():
closed = []
class _C(_FakeClient):
def get_positions(self):
def get_positions(self, fresh=False):
return [{"symbol": "AAA", "qty": "100"}, {"symbol": "BBB", "qty": "50"}]
def close_position(self, symbol, qty=None, percentage=None):
+4
View File
@@ -291,6 +291,10 @@ def test_unfilled_entry_invalidated_by_price_break(store):
)
service.tick()
assert len(service._armed) == 1
# backdate past the fill-grace window so the price-break logic can act
from datetime import datetime, timedelta
for a in service._armed.values():
a["armed_at"] = datetime.now() - timedelta(seconds=30)
# price holds above entry -> no back-out
assert service.expire_stale_entries() == []
# price breaks back below the entry trigger -> cancel
+19 -1
View File
@@ -126,7 +126,7 @@ class _FakeClient:
def get_account(self):
return self.account
def get_positions(self):
def get_positions(self, fresh=False):
return self._positions
def close_position(self, symbol):
@@ -196,3 +196,21 @@ def test_guard_never_cancels_a_filled_position():
assert backed == [] # not backed out
assert client.canceled == [] # stop/TP legs NOT stripped
assert "o1" not in svc._armed # but we stop tracking the filled entry
def test_guard_grace_window_protects_just_armed_entry():
"""A marketable entry that just armed (still filling) must NOT be cancelled
even if price dipped below the trigger — cancelling strips its bracket."""
from datetime import datetime
store = EventStore(":memory:")
client = _FakeClient() # no positions reported yet (fill not reflected)
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(entry_invalidate_pct=0.0, entry_grace_seconds=5,
max_daily_loss_pct=0.5),
session_id="t", price_provider=lambda s: 0.5, # well below trigger
)
svc._armed["o1"] = {"symbol": "AAA", "entry_price": 1.0, "broker_order_id": "b1",
"armed_at": datetime.now(), "checks": 0} # armed just now
assert svc.expire_stale_entries() == [] # grace -> not cancelled
assert "o1" in svc._armed and client.canceled == []
+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