diff --git a/alpaca_paper/client.py b/alpaca_paper/client.py index 253a5cd..da10d21 100644 --- a/alpaca_paper/client.py +++ b/alpaca_paper/client.py @@ -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( diff --git a/tests/unit/test_execution_safety.py b/tests/unit/test_execution_safety.py index 07ae909..787af73 100644 --- a/tests/unit/test_execution_safety.py +++ b/tests/unit/test_execution_safety.py @@ -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): diff --git a/tests/unit/test_trading_execution.py b/tests/unit/test_trading_execution.py index 7d4f778..8042b8f 100644 --- a/tests/unit/test_trading_execution.py +++ b/tests/unit/test_trading_execution.py @@ -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 diff --git a/tests/unit/test_triggers.py b/tests/unit/test_triggers.py index 8f95dfa..c352904 100644 --- a/tests/unit/test_triggers.py +++ b/tests/unit/test_triggers.py @@ -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 == [] diff --git a/trading_execution.py b/trading_execution.py index 64817aa..fecf5e6 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -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