Execution: per-symbol backout cooldown (anti-thrash)
A breakout that won't fill on a marketable limit used to re-arm every pass: the
entry timed out (~2 min), backed out, freed the symbol, and re-armed next tick —
forever. Observed live: NEOV armed+backed-out 68x in one session, 106 of 130
buy orders cancelled. That order churn is also what truncated get_orders and
caused the false-naked scare (commit 11ab6c7).
Add a cooldown: after an entry backs out (timeout or price-break), bench the
symbol for TRADING_BACKOUT_COOLDOWN_SECONDS (180s); after
TRADING_MAX_BACKOUTS_PER_SYMBOL (3) backouts, bench it for the rest of the
session. Gated on both the slow path (request_approvals_for_ready_signals) and
the fast trigger (submit_breakout_now). Backout events now carry the cooldown
state.
Verified live: NEOV's first backout under the new code stamped
"[cooldown 180s (backout 1)]" and the symbol stayed ready every pass but
approvals_requested=[] for the full window — no re-arm. Tests: two new
(temporary cooldown + session bench), 39 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
11ab6c7b56
commit
34d22c9193
2 files changed
+106
-4
No files matched your search
@@ -343,3 +343,53 @@ def test_backout_frees_symbol_to_resignal(store):
|
||||
service.expire_stale_entries()
|
||||
# after backing out, the symbol is no longer marked as requested
|
||||
assert symbol not in service._requested_symbols
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backout cooldown (anti-thrash): a name that won't fill is benched, not
|
||||
# re-armed every pass (NEOV armed+backed-out 68x in one live session).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backout_benches_symbol_until_cooldown_elapses(store):
|
||||
emit_signal(store)
|
||||
executor = FakeExecutor(status="accepted")
|
||||
clock = Clock()
|
||||
service = make_service(
|
||||
store, executor, auto_approve=True, now_fn=clock,
|
||||
entry_timeout_bars=1, entry_invalidate_pct=-1.0,
|
||||
backout_cooldown_seconds=120, max_backouts_per_symbol=3,
|
||||
)
|
||||
service.tick()
|
||||
sym = service._armed[next(iter(service._armed))]["symbol"]
|
||||
clock.advance(1) # hit the timeout -> backs out
|
||||
out = service.expire_stale_entries()
|
||||
assert len(out) == 1 and "cooldown" in out[0]
|
||||
# benched right after the backout; a fresh ready signal must NOT re-arm it
|
||||
assert service._in_cooldown(sym)
|
||||
emit_signal(store)
|
||||
assert service.request_approvals_for_ready_signals() == []
|
||||
# still benched just before the window closes...
|
||||
clock.advance(1.9) # 114s elapsed (< 120)
|
||||
assert service._in_cooldown(sym)
|
||||
# ...and eligible once the cooldown elapses
|
||||
clock.advance(0.2) # 126s elapsed (> 120)
|
||||
assert not service._in_cooldown(sym)
|
||||
assert len(service.request_approvals_for_ready_signals()) == 1
|
||||
|
||||
|
||||
def test_repeated_backouts_bench_symbol_for_session(store):
|
||||
clock = Clock()
|
||||
service = make_service(
|
||||
store, now_fn=clock,
|
||||
backout_cooldown_seconds=60, max_backouts_per_symbol=3,
|
||||
)
|
||||
# first two backouts only cool the name temporarily
|
||||
for _ in range(2):
|
||||
service._register_backout("THRASH")
|
||||
assert service._in_cooldown("THRASH")
|
||||
clock.advance(2) # 120s > 60s -> cooldown elapses
|
||||
assert not service._in_cooldown("THRASH")
|
||||
# the third hits the cap -> benched for the rest of the session
|
||||
service._register_backout("THRASH")
|
||||
clock.advance(10_000) # hours later, still benched
|
||||
assert service._in_cooldown("THRASH")
|
||||
+56
-4
@@ -20,7 +20,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from alpaca_paper.execution import (
|
||||
AlpacaPaperExecutor,
|
||||
@@ -107,6 +107,15 @@ class ExecutionSettings:
|
||||
# liquidity cap: shares <= this fraction of the symbol's day volume so at
|
||||
# SIZE you don't move the market. 0 = off (irrelevant at small size).
|
||||
liquidity_max_volume_pct: float = 0.0
|
||||
# --- backout cooldown (anti-thrash) ------------------------------------
|
||||
# after an entry backs out (timeout / price-break) the symbol is benched for
|
||||
# this many seconds before it can re-arm. Without it a breakout that won't
|
||||
# fill on a marketable limit re-arms every pass and churns (observed live:
|
||||
# NEOV armed+backed-out 68x in one session, bloating the order list). 0 = off.
|
||||
backout_cooldown_seconds: float = 180.0
|
||||
# after this many backouts in a session, bench the symbol for the rest of the
|
||||
# day (a setup that has failed to fill repeatedly isn't going to start). 0 = no cap.
|
||||
max_backouts_per_symbol: int = 3
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: dict[str, str] | None = None) -> "ExecutionSettings":
|
||||
@@ -146,6 +155,12 @@ class ExecutionSettings:
|
||||
liquidity_max_volume_pct=float(
|
||||
values.get("TRADING_LIQUIDITY_MAX_VOLUME_PCT", "0.0")
|
||||
),
|
||||
backout_cooldown_seconds=float(
|
||||
values.get("TRADING_BACKOUT_COOLDOWN_SECONDS", "180")
|
||||
),
|
||||
max_backouts_per_symbol=int(
|
||||
values.get("TRADING_MAX_BACKOUTS_PER_SYMBOL", "3")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -184,6 +199,12 @@ class TradingExecutionService:
|
||||
# armed (submitted, awaiting fill) entries we may need to cancel.
|
||||
# order_id -> {symbol, entry_price, broker_order_id, armed_at, checks}
|
||||
self._armed: dict[str, dict] = {}
|
||||
# backout cooldown (anti-thrash): symbol -> wall-clock time it may re-arm,
|
||||
# and how many times it has backed out this session. A symbol that won't
|
||||
# fill is benched instead of re-arming every pass. datetime.max == benched
|
||||
# for the rest of the session (hit max_backouts_per_symbol).
|
||||
self._cooldown_until: dict[str, datetime] = {}
|
||||
self._backout_counts: dict[str, int] = {}
|
||||
# daily-loss circuit breaker: once tripped, no new entries this session
|
||||
# (a REAL loss is permanent for the session — a bounce can't re-open it)
|
||||
self._halted = False
|
||||
@@ -440,7 +461,9 @@ class TradingExecutionService:
|
||||
if len(created) >= self.settings.max_orders_per_tick:
|
||||
break
|
||||
symbol = signal["symbol"]
|
||||
if symbol in held or symbol in pending or symbol in self._requested_symbols:
|
||||
if (symbol in held or symbol in pending
|
||||
or symbol in self._requested_symbols
|
||||
or self._in_cooldown(symbol)): # benched after recent backouts
|
||||
continue
|
||||
entry = signal.get("entry_price")
|
||||
stop = signal.get("stop_loss_price")
|
||||
@@ -580,6 +603,8 @@ class TradingExecutionService:
|
||||
pending = {row["symbol"] for row in query_approval_queue(self.store)}
|
||||
if symbol in held or symbol in pending or symbol in self._requested_symbols:
|
||||
return {"ok": False, "skipped": "already_active"}
|
||||
if self._in_cooldown(symbol): # benched after recent backouts (anti-thrash)
|
||||
return {"ok": False, "skipped": "cooldown"}
|
||||
|
||||
entry_ref = float(last_price or trigger)
|
||||
stop_f = float(stop)
|
||||
@@ -813,6 +838,30 @@ class TradingExecutionService:
|
||||
return ids
|
||||
|
||||
@_locked
|
||||
def _in_cooldown(self, symbol: str) -> bool:
|
||||
"""True if symbol is benched after recent backouts (anti-thrash)."""
|
||||
until = self._cooldown_until.get(symbol)
|
||||
if until is None:
|
||||
return False
|
||||
if self._now() >= until:
|
||||
del self._cooldown_until[symbol] # cooldown elapsed
|
||||
return False
|
||||
return True
|
||||
|
||||
def _register_backout(self, symbol: str) -> str:
|
||||
"""Record a backout and bench the symbol; returns a short reason suffix."""
|
||||
n = self._backout_counts.get(symbol, 0) + 1
|
||||
self._backout_counts[symbol] = n
|
||||
cap = self.settings.max_backouts_per_symbol
|
||||
if cap and n >= cap:
|
||||
self._cooldown_until[symbol] = datetime.max # benched for the session
|
||||
return f"benched for session ({n} backouts)"
|
||||
secs = self.settings.backout_cooldown_seconds
|
||||
if secs > 0:
|
||||
self._cooldown_until[symbol] = self._now() + timedelta(seconds=secs)
|
||||
return f"cooldown {secs:.0f}s (backout {n})"
|
||||
return f"backout {n}"
|
||||
|
||||
def expire_stale_entries(self) -> list[dict]:
|
||||
"""Back out of unfilled entries that timed out or broke their trigger.
|
||||
|
||||
@@ -895,25 +944,28 @@ class TradingExecutionService:
|
||||
self.executor.cancel_entry(
|
||||
order_id, armed.get("broker_order_id"), symbol, reason
|
||||
)
|
||||
bench = self._register_backout(symbol) # anti-thrash: cool the name
|
||||
self.store.emit(
|
||||
RiskRuleTriggeredEvent(
|
||||
timestamp=datetime.now(),
|
||||
mode=self.mode,
|
||||
correlation_id=self.session_id,
|
||||
message=f"Backed out of {symbol} entry — {reason}",
|
||||
message=f"Backed out of {symbol} entry — {reason} [{bench}]",
|
||||
rule_type="entry_backout",
|
||||
rule_value=float(timeout_bars),
|
||||
current_state={
|
||||
"symbol": symbol,
|
||||
"order_id": order_id,
|
||||
"checks": armed["checks"],
|
||||
"backouts": self._backout_counts.get(symbol, 0),
|
||||
},
|
||||
action_taken="cancelled_unfilled_entry",
|
||||
)
|
||||
)
|
||||
self._armed.pop(order_id, None)
|
||||
self._requested_symbols.discard(symbol)
|
||||
actions.append({"order_id": order_id, "symbol": symbol, "reason": reason})
|
||||
actions.append({"order_id": order_id, "symbol": symbol,
|
||||
"reason": reason, "cooldown": bench})
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
Reference in new issue
Block a user