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:
momentum devandClaude Opus 4.8 committed 2026-06-18 13:21:20 -04:00
1 parent 11ab6c7b56
commit 34d22c9193
2 files changed
+106 -4

No files matched your search

+50
View File
@@ -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
View File
@@ -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