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:
1 parent
27b0748b71
commit
1cd82de6a8
5 files changed
+43
-4
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user