The 2026-07-08 audit (owner: "figure out if there is some kind of logic or audit issue") found BOTH entry paths feeding the population gate the WRONG gap. The fast path used the trigger book's dynamic gap (latest price vs prev close), so intraday rallies masqueraded as gappers: TVRD (6.0% true gap), SKIN (2.2%, filled at $0.98), JLHL (4.3%) all traded outside the validated population. The slow path used max(gap, intraday) with "open" taken from the first lookback-window bar, and had no upper band at all — dead-band names SEER (38.6%) and IOTR (40.7%) entered. Fixes: candidates carry gap_open_pct (session open vs prev close); trigger eligibility + submit_breakout_now use it exclusively once the session is open; _population_reject now implements the full validated band (10-35% OR volume-verified >=150% squeeze, $1-20 price band) for both paths; evaluator gap uses the session open; signals carry relative_volume for squeeze verification. Plus momentum-testpg.service so the test-isolation Postgres survives reboots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1666 lines
82 KiB
Python
1666 lines
82 KiB
Python
"""Trading execution service (Milestone 4/5 glue).
|
|
|
|
Connects ready signals to broker execution with risk controls and an
|
|
approval workflow:
|
|
|
|
signal_ready
|
|
-> risk checks (sizing, max concurrent, daily-loss circuit breaker)
|
|
-> order_approval_requested
|
|
-> [manual approval in the dashboard] or [auto-approve]
|
|
-> order_approved -> broker submit -> order_submitted (+ filled)
|
|
|
|
Exit orders close existing broker positions on demand.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
|
|
from runtime.flatten import (buy_fills_from_orders, cancel_protective_and_close,
|
|
find_overnight_carries)
|
|
from alpaca_paper.execution import (
|
|
AlpacaPaperExecutor,
|
|
ExecutionRequest,
|
|
)
|
|
from storage.event_schema import (
|
|
EventMode,
|
|
OrderApprovalRequestedEvent,
|
|
OrderApprovedEvent,
|
|
OrderRejectedEvent,
|
|
RiskRuleTriggeredEvent,
|
|
)
|
|
from storage.event_store import EventStore
|
|
from storage.projections import (
|
|
query_account_positions_snapshot,
|
|
query_approval_queue,
|
|
query_ready_signals_snapshot,
|
|
)
|
|
from strategy.risk.position_sizing import (
|
|
PositionSizingConfig,
|
|
calculate_position_size,
|
|
rank_risk_factor,
|
|
)
|
|
from trading_mode import TradingModeSettings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _locked(method):
|
|
"""Serialize a mutating service method behind self._lock (a re-entrant lock).
|
|
|
|
The fast trigger thread (submit_breakout_now) and the main loop (tick,
|
|
approvals) both mutate execution state (_armed, _requested_symbols) and emit
|
|
events; without this they would race on the shared psycopg2 connection and
|
|
the in-memory sets. RLock so a public method may call another (tick ->
|
|
expire_stale_entries) without deadlocking.
|
|
"""
|
|
@functools.wraps(method)
|
|
def wrapper(self, *args, **kwargs):
|
|
with self._lock:
|
|
return method(self, *args, **kwargs)
|
|
return wrapper
|
|
|
|
|
|
@dataclass
|
|
class ExecutionSettings:
|
|
enabled: bool = True
|
|
auto_approve: bool = False
|
|
max_orders_per_tick: int = 1
|
|
max_concurrent_positions: int = 3
|
|
risk_per_trade_pct: float = 0.01
|
|
default_equity: float = 100_000.0
|
|
# CONCENTRATE-BY-RANK (stop spraying equal size across mediocre gappers — the
|
|
# human's edge is concentration). Only the top-N armed names by rank may enter,
|
|
# and risk is scaled DOWN by rank (rank-1 full, rank-2 half). 0 = off (every
|
|
# armed name sizes equally as before). Plus a hard per-DAY fresh-entry cap.
|
|
concentrate_top_n: int = 0
|
|
max_fresh_entries_per_day: int = 0 # 0 = no daily cap
|
|
# VWAP SELECTION GATE — the one validated entry-quality signal (above-VWAP
|
|
# breakouts reach +1R ~1.5x as often, n=3,110 from the labeler lift report).
|
|
# Every below-VWAP ready signal is shadow-logged (rule_type=vwap_below) for
|
|
# measurement; entries are only SKIPPED when this is True. Fail-open: a signal
|
|
# with above_vwap=None (missing the field) is never blocked.
|
|
require_above_vwap: bool = False
|
|
# QUALITY-GRADE GATE — "trade fewer, higher-quality setups". The per-signal setup
|
|
# grade (A>=0.80, B>=0.65, C>=0.50, else F) is on the ready-signal snapshot; a signal
|
|
# scoring below this is a low-quality/chop setup. Every sub-threshold signal is
|
|
# shadow-logged (rule_type=quality_below) for measurement; entries are only SKIPPED
|
|
# when min_quality_score > 0. Fail-open: a signal with no quality_score is never
|
|
# blocked. NOTE: the grade rewards a clean impulse-pullback structure + RVOL, so it
|
|
# does NOT recognise a vertical catalyst RUNNER (those grade F) — this gate cuts
|
|
# chop, it does not select leading gainers.
|
|
min_quality_score: float = 0.0
|
|
# CONFIRMATION-SPEED GATE — the one backtested POSITIVE-EV filter (2026-07-02,
|
|
# 6217 ORB setups, adversarially verified across 3 seeds): setups whose breakout
|
|
# CONFIRMS within ~10 bars of the open run +1R 71% / +0.42R expectancy — even
|
|
# C-grade ones — while late-confirming setups are dead regardless of grade
|
|
# (bar-26+: 9% +1R, -0.81R; time-of-day expectancy decays monotonically to
|
|
# -0.94R late-day). Minutes after the 09:30 open by which a signal must have
|
|
# gone READY to be tradeable; later signals (incl. midday re-triggers) are
|
|
# skipped + shadow-logged (rule_type=late_confirmation). 0 = disabled.
|
|
entry_confirm_by_minute: int = 0
|
|
# POPULATION GATE — the year-long gapper replay (research/gapper_history.py, n=3,008
|
|
# symbol-days / 14 months) found the first at-scale +EV boundary: top-ranked gappers
|
|
# with gap >= 10% and real day volume are +0.05..+0.15R under the live ratchet exits,
|
|
# in EVERY rank x timing segment — while the live book bled by trading OUTSIDE that
|
|
# population (low-gap / thin names). Floors are FRACTION (0.10 = 10%) and shares.
|
|
# 0 = disabled. Fail-open: a signal missing gap/volume data is never blocked.
|
|
min_entry_gap_pct: float = 0.0
|
|
min_day_volume: float = 0.0
|
|
# UNIFIED ENTRY: run the fast path's shared anti-chase gates (over_extended,
|
|
# day-extension, halt) on the LIVE auto path too. These previously ran ONLY on
|
|
# the fast trigger path, leaving live auto entries unprotected (see the
|
|
# consolidation plan). Fail-open on missing data. False = pre-unification.
|
|
unified_entry: bool = False
|
|
# Auto-path FILL MODEL (the last real divergence from the fast path):
|
|
# "resting" — rest a buy-limit AT the breakout level; fills only on a
|
|
# pullback to it (today's behaviour; misses runners that gap
|
|
# up and never return).
|
|
# "marketable" — limit a hair above the trigger (entry * (1+slippage)); fills
|
|
# as price breaks UP through the level (what the fast path
|
|
# does), catching runners. The limit caps the fill price, so a
|
|
# halt-resume gap-through can't be chased.
|
|
entry_fill_model: str = "resting"
|
|
max_daily_loss_pct: float = 0.03
|
|
# on a daily-loss breach, also flatten open positions + cancel unfilled entries
|
|
flatten_on_breach: bool = True
|
|
# --- entry mechanism (Ross-Cameron-style; all tunable) -----------------
|
|
# reward target as a multiple of risk (entry + reward_multiple * (entry-stop))
|
|
reward_multiple: float = 2.0
|
|
# how the entry order is placed: "limit" rests at the entry trigger so an
|
|
# unfilled order is a real, cancellable state; "market" fills immediately
|
|
entry_order_type: str = "limit"
|
|
# cancel an unfilled entry after this many minutes of resting (the "back
|
|
# out" time box; ~1 bar == 1 minute). 0 disables the timeout. Wall-clock
|
|
# based, so the invalidation guard can run far more often than this.
|
|
entry_timeout_bars: int = 2
|
|
# cancel an unfilled entry if price trades back below the entry trigger by
|
|
# this fraction. NOT 0.0: at zero tolerance any sub-cent wobble below the
|
|
# trigger cancels the entry one tick after it fires, so a breakout that
|
|
# oscillates around the level churns and never holds (observed live). Give
|
|
# it room — the bracket STOP (opening-range low) is the real protection and
|
|
# risk is capped at 1%/trade by sizing. Negative disables price-break cancel.
|
|
entry_invalidate_pct: float = 0.015
|
|
# live-trigger fast path (submit_breakout_now): how far above the trigger to
|
|
# 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).
|
|
max_position_pct: float = 0.33
|
|
# 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
|
|
# HARD dollar-risk ceiling per trade (fixed-fractional with a cap): dollar
|
|
# risk = min(equity*risk_per_trade_pct, max_risk_dollars). Without it the %
|
|
# budget alone let wide-stop names risk ~3x the median (the ~-$1k losers in
|
|
# the loss diagnosis). 0 = off (the % budget governs, e.g. a small account).
|
|
max_risk_dollars: float = 0.0
|
|
# PORTFOLIO gross-notional cap: total $ across ALL open+pending positions must
|
|
# stay under this fraction of equity. Without it, max_concurrent (6) x
|
|
# max_position_pct (0.16) = ~96% gross in correlated small-cap gappers — one
|
|
# regime move hits the whole book together. A later entry is shrunk to fit the
|
|
# remaining budget, or blocked if none remains. 0 = off.
|
|
max_gross_notional_pct: float = 0.60
|
|
# --- 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
|
|
# once a position in a symbol closes at a LOSS, don't re-enter that name this
|
|
# session. Stops the "throw good money after bad" re-entry into a name that
|
|
# already failed (observed live: APWC stopped out -904, was bought back, lost
|
|
# another -1012). Only blocks LOSING exits (see reentry_min_loss_pct) so a
|
|
# quick scratch-then-re-enter winner isn't killed (GRAB +513 in the replay).
|
|
reentry_block_after_exit: bool = True
|
|
# only bench a closed name if it exited DOWN more than this fraction — a real
|
|
# stop-out, not a scratch/win. 0 = bench on any close (the blunt original).
|
|
reentry_min_loss_pct: float = 0.01
|
|
# --- anti-chase / halt protection (the "+100% then 3 halts down" failure) ---
|
|
# Block a live entry whose price has already extended more than this fraction
|
|
# ABOVE the trigger (opening-range high). A clean break crosses the level
|
|
# smoothly (~0% extension and passes); a violent gap-THROUGH — a parabolic
|
|
# spike, or a halt reopening past the level — lands far above it, and chasing
|
|
# that buys the top tick that then reverses/halts down. 0 = off.
|
|
# Default 0.15 calibrated on real fires: historical entries cluster <=9.8%
|
|
# extension, with a clean gap to a disaster tail at 26-64% (NEOV x4 @27%,
|
|
# ICCM @64%/26%, BIRD @30%). 15% sits in that dead zone — it blocks the
|
|
# spike-top chases and passes every normal breakout.
|
|
extension_max_pct: float = 0.15
|
|
# Day-extension ceiling: block when the entry is more than this fraction ABOVE
|
|
# the SESSION OPEN — i.e. the stock is already parabolic on the day. This
|
|
# catches what extension_max_pct can't: a name up huge intraday whose ORB-high
|
|
# break is itself clean (small trigger-extension) but is a chase of a parabolic
|
|
# (e.g. ATPC entered +1.7% above trigger yet +32% above the open, and lost).
|
|
# Default 0.30 calibrated on real fires: normal entries are <=14% above the
|
|
# open, the parabolic tail is 20-83%. 0 = off. Needs day_open from the trigger.
|
|
day_extension_max_pct: float = 0.30
|
|
# Skip entries on a name that looks HALTED: in a trading halt you can't exit
|
|
# and it gaps through your stop on resume. The caller passes a per-symbol
|
|
# halt flag (a bar-gap heuristic over the liquid gappers we trade). This flag
|
|
# only gates whether that signal is honoured.
|
|
halt_guard_enabled: bool = True
|
|
# treat a symbol as halted when, during RTH, its freshest minute bar is older
|
|
# than this many minutes WHILE other names are still printing (LULD halts run
|
|
# >= 5 min, so 3 catches a real halt without tripping on normal IEX lag).
|
|
halt_max_bar_gap_min: float = 3.0
|
|
# --- catalyst dilution veto (Phase 2; ships OFF) -----------------------
|
|
# block an entry when the LLM catalyst advisory says the move is driven by a
|
|
# CONFIRMED dilutive offering (a gap that fades as new shares hit). Requires
|
|
# a catalyst_provider AND a conviction at/above the floor below. Default OFF
|
|
# so the LLM stays advisory-only until its accuracy is trusted.
|
|
catalyst_veto_enabled: bool = False
|
|
catalyst_veto_min_conviction: float = 0.6
|
|
|
|
@classmethod
|
|
def from_env(cls, env: dict[str, str] | None = None) -> "ExecutionSettings":
|
|
values = dict(os.environ)
|
|
if env is not None:
|
|
values.update(env)
|
|
|
|
def flag(key: str, default: str) -> bool:
|
|
return values.get(key, default).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
def _qfloat(v: str) -> float:
|
|
try:
|
|
return max(0.0, min(1.0, float(v)))
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
order_type = values.get("TRADING_ENTRY_ORDER_TYPE", "limit").strip().lower()
|
|
if order_type not in {"limit", "market"}:
|
|
order_type = "limit"
|
|
|
|
return cls(
|
|
enabled=flag("TRADING_EXECUTION_ENABLED", "1"),
|
|
auto_approve=flag("TRADING_AUTO_APPROVE", "0"),
|
|
max_orders_per_tick=int(values.get("TRADING_MAX_ORDERS_PER_TICK", "1")),
|
|
max_concurrent_positions=int(
|
|
values.get("TRADING_MAX_CONCURRENT_POSITIONS", "3")
|
|
),
|
|
risk_per_trade_pct=float(values.get("TRADING_RISK_PER_TRADE_PCT", "0.01")),
|
|
default_equity=float(values.get("TRADING_DEFAULT_EQUITY", "100000")),
|
|
max_daily_loss_pct=float(values.get("TRADING_MAX_DAILY_LOSS_PCT", "0.03")),
|
|
flatten_on_breach=flag("TRADING_FLATTEN_ON_BREACH", "1"),
|
|
reward_multiple=float(values.get("TRADING_REWARD_MULTIPLE", "2.0")),
|
|
entry_order_type=order_type,
|
|
entry_timeout_bars=int(values.get("TRADING_ENTRY_TIMEOUT_BARS", "2")),
|
|
entry_invalidate_pct=float(
|
|
values.get("TRADING_ENTRY_INVALIDATE_PCT", "0.015")
|
|
),
|
|
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")),
|
|
max_risk_dollars=float(values.get("TRADING_MAX_RISK_DOLLARS", "0.0")),
|
|
concentrate_top_n=int(values.get("TRADING_CONCENTRATE_TOP_N", "0")),
|
|
max_fresh_entries_per_day=int(values.get("TRADING_MAX_FRESH_ENTRIES_PER_DAY", "0")),
|
|
require_above_vwap=flag("TRADING_REQUIRE_ABOVE_VWAP", "1"),
|
|
min_quality_score=_qfloat(values.get("TRADING_MIN_QUALITY_SCORE", "0.0")),
|
|
entry_confirm_by_minute=int(values.get("TRADING_ENTRY_CONFIRM_BY_MINUTE", "0")),
|
|
min_entry_gap_pct=_qfloat(values.get("TRADING_MIN_ENTRY_GAP_PCT", "0.0")),
|
|
min_day_volume=float(values.get("TRADING_MIN_DAY_VOLUME", "0.0")),
|
|
unified_entry=flag("TRADING_UNIFIED_ENTRY", "1"),
|
|
entry_fill_model=values.get("TRADING_ENTRY_FILL_MODEL", "resting"),
|
|
liquidity_max_volume_pct=float(
|
|
values.get("TRADING_LIQUIDITY_MAX_VOLUME_PCT", "0.0")
|
|
),
|
|
max_gross_notional_pct=float(
|
|
values.get("TRADING_MAX_GROSS_NOTIONAL_PCT", "0.60")
|
|
),
|
|
backout_cooldown_seconds=float(
|
|
values.get("TRADING_BACKOUT_COOLDOWN_SECONDS", "180")
|
|
),
|
|
max_backouts_per_symbol=int(
|
|
values.get("TRADING_MAX_BACKOUTS_PER_SYMBOL", "3")
|
|
),
|
|
reentry_block_after_exit=flag("TRADING_BLOCK_REENTRY_AFTER_EXIT", "1"),
|
|
reentry_min_loss_pct=float(
|
|
values.get("TRADING_REENTRY_MIN_LOSS_PCT", "0.01")
|
|
),
|
|
extension_max_pct=float(
|
|
values.get("TRADING_EXTENSION_MAX_PCT", "0.15")
|
|
),
|
|
day_extension_max_pct=float(
|
|
values.get("TRADING_DAY_EXTENSION_MAX_PCT", "0.30")
|
|
),
|
|
halt_guard_enabled=flag("TRADING_HALT_GUARD_ENABLED", "1"),
|
|
halt_max_bar_gap_min=float(
|
|
values.get("TRADING_HALT_MAX_BAR_GAP_MIN", "3")
|
|
),
|
|
catalyst_veto_enabled=flag("NEWS_DILUTION_VETO_ENABLED", "0"),
|
|
catalyst_veto_min_conviction=float(
|
|
values.get("NEWS_DILUTION_VETO_CONVICTION", "0.6")
|
|
),
|
|
)
|
|
|
|
|
|
class TradingExecutionService:
|
|
"""Drives the signal -> approval -> order pipeline each tick."""
|
|
|
|
def __init__(
|
|
self,
|
|
store: EventStore,
|
|
executor: AlpacaPaperExecutor | None = None,
|
|
settings: ExecutionSettings | None = None,
|
|
trading_mode: TradingModeSettings | None = None,
|
|
session_id: str | None = None,
|
|
equity: float | None = None,
|
|
price_provider=None,
|
|
now_fn=None,
|
|
catalyst_provider=None,
|
|
):
|
|
self.store = store
|
|
self.settings = settings or ExecutionSettings.from_env()
|
|
# optional callable: symbol -> catalyst advisory dict (or None), from the
|
|
# LLM enrichment cache. Used ONLY for the (gated) dilution veto. Injected
|
|
# so the execution service never reads the DB itself.
|
|
self.catalyst_provider = catalyst_provider
|
|
self.trading_mode = trading_mode or TradingModeSettings.from_env()
|
|
self.session_id = session_id
|
|
self.equity = equity or self.settings.default_equity
|
|
self.executor = executor or AlpacaPaperExecutor(
|
|
store, session_id=session_id
|
|
)
|
|
self.mode = EventMode.PAPER
|
|
# optional callable: symbol -> latest price, used to invalidate unfilled
|
|
# entries that break back below the entry trigger before filling
|
|
self.price_provider = price_provider
|
|
# injectable clock so the entry timeout is wall-clock based (and so the
|
|
# invalidation check can run far more often than the timeout window
|
|
# without changing the timeout's meaning); tests pass a fake clock
|
|
self._now = now_fn or datetime.now
|
|
# signals we've already requested approval for this session
|
|
self._requested_symbols: set[str] = set()
|
|
# 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] = {}
|
|
# re-entry block: symbols whose position has CLOSED this session (detected
|
|
# by a name leaving the broker's held set) — benched from re-entry so we
|
|
# don't buy back a name that just stopped out. _prev_held is the last
|
|
# fresh broker snapshot used to spot departures.
|
|
self._exited_today: set[str] = set()
|
|
self._fresh_entries: dict = {"date": None, "n": 0} # per-day fresh-entry cap
|
|
self._prev_held: set[str] = set()
|
|
# last-seen unrealized return (fraction) per held symbol — used to decide,
|
|
# when a name leaves the book, whether it exited at a real loss (bench) or
|
|
# a scratch/win (allow re-entry).
|
|
self._held_ret: dict[str, float] = {}
|
|
# 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
|
|
# transient data-halt: equity unreadable (network/DNS). Blocks new entries
|
|
# WHILE unreadable but RECOVERS on the next good read — a DNS blip must not
|
|
# end the trading day the way a real loss does.
|
|
self._data_halt = False
|
|
# session closed (end-of-day flatten): no new entries for the rest of the day
|
|
self._session_closed = False
|
|
# consecutive equity-read failures; after the limit we pause (recoverable)
|
|
self._equity_read_failures = 0
|
|
self._equity_fail_limit = int(
|
|
os.environ.get("TRADING_EQUITY_FAIL_LIMIT", "5")
|
|
)
|
|
# serializes state mutations across the main loop and the trigger thread
|
|
self._lock = threading.RLock()
|
|
|
|
# -- pipeline -----------------------------------------------------------
|
|
|
|
def _open_position_count(self) -> int:
|
|
snapshots = query_account_positions_snapshot(
|
|
self.store, broker_name=self.executor.broker_name
|
|
)
|
|
if not snapshots:
|
|
return 0
|
|
return len(snapshots[-1].get("positions") or [])
|
|
|
|
def _held_symbols(self) -> set[str]:
|
|
snapshots = query_account_positions_snapshot(
|
|
self.store, broker_name=self.executor.broker_name
|
|
)
|
|
if not snapshots:
|
|
return set()
|
|
return {
|
|
str(p.get("symbol"))
|
|
for p in snapshots[-1].get("positions") or []
|
|
if p.get("symbol")
|
|
}
|
|
|
|
def _current_equity(self) -> float:
|
|
"""Size off the REAL broker equity, not the $100k default.
|
|
|
|
On a $300 live account the default would size $1,000 positions and get
|
|
rejected; this reads actual equity (the get_account read is cached, so
|
|
it's cheap) and caches it on the service for fallback.
|
|
"""
|
|
client = getattr(self.executor, "client", None)
|
|
if client is not None and hasattr(client, "get_account"):
|
|
try:
|
|
eq = float((client.get_account() or {}).get("equity") or 0.0)
|
|
if eq > 0:
|
|
self.equity = eq
|
|
return eq
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return self.equity
|
|
|
|
def _remaining_gross_budget(self, equity: float) -> float:
|
|
"""$ of new notional still allowed under the portfolio gross-notional cap
|
|
(cap*equity minus the market value of currently-open positions). Inf when
|
|
the cap is disabled. Caps how big the NEXT entry can be so the whole book
|
|
can't pile into ~100% gross of correlated small-caps."""
|
|
cap = float(self.settings.max_gross_notional_pct or 0.0)
|
|
if cap <= 0:
|
|
return float("inf")
|
|
positions = self._broker_positions() or []
|
|
open_gross = 0.0
|
|
for p in positions:
|
|
try:
|
|
open_gross += abs(float(p.get("market_value") or 0.0))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return max(0.0, cap * equity - open_gross)
|
|
|
|
def _broker_held_symbols(self) -> set[str] | None:
|
|
"""Symbols the broker reports as OPEN POSITIONS right now (truth).
|
|
|
|
Used to guard entry cancellation: our own order_filled events lag the
|
|
actual fill (they arrive via the 60s account sync), so the entry-timeout
|
|
guard once cancelled an entry it thought was unfilled AFTER it had really
|
|
filled — and cancelling a bracket parent cancels its stop/take-profit
|
|
legs, leaving the position NAKED. This queries the broker directly so a
|
|
filled name is never cancelled. None if positions can't be read (caller
|
|
then falls back to the synced snapshot and stays conservative).
|
|
"""
|
|
client = getattr(self.executor, "client", None)
|
|
if client is None or not hasattr(client, "get_positions"):
|
|
return None
|
|
try:
|
|
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)
|
|
return None
|
|
|
|
def _release_and_close(self, client, symbol: str) -> None:
|
|
"""Cancel a symbol's resting bracket legs, then market-close it.
|
|
|
|
Thin wrapper over the shared helper (runtime/flatten.py) — the held-qty
|
|
403 fix lives in one place, shared with the live exit manager. Fetches
|
|
the symbol's open orders itself (no pre-fetched snapshot at EOD)."""
|
|
cancel_protective_and_close(client, symbol)
|
|
|
|
def _flatten_all(self, reason: str) -> dict:
|
|
"""Cancel unfilled entries and market-close every open position.
|
|
|
|
Used at EOD and when the daily-loss circuit breaker trips: stop the
|
|
bleeding by cancelling resting entries and flattening the book.
|
|
Best-effort — errors are collected, not raised, so one bad symbol can't
|
|
block the rest. Resting bracket legs are cancelled before each close so
|
|
the held quantity is freed (else close_position 403s)."""
|
|
result: dict = {"cancelled_entries": 0, "closed_positions": [], "errors": []}
|
|
# 1) cancel unfilled (armed) entries we are tracking
|
|
for order_id in list(self._armed):
|
|
armed = self._armed.pop(order_id)
|
|
symbol = armed.get("symbol", "")
|
|
try:
|
|
self.executor.cancel_entry(
|
|
order_id, armed.get("broker_order_id"), symbol, reason
|
|
)
|
|
result["cancelled_entries"] += 1
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"cancel {symbol}: {exc}")
|
|
self._requested_symbols.discard(symbol)
|
|
# 2) market-close open positions
|
|
client = getattr(self.executor, "client", None)
|
|
for symbol in sorted(self._held_symbols()):
|
|
if client is None or not hasattr(client, "close_position"):
|
|
result["errors"].append(f"close {symbol}: no broker client to flatten")
|
|
continue
|
|
try:
|
|
self._release_and_close(client, symbol)
|
|
result["closed_positions"].append(symbol) # only on a confirmed close
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"close {symbol}: {exc}")
|
|
return result
|
|
|
|
@_locked
|
|
def _daily_loss_breach(self) -> bool:
|
|
"""True while new entries should be blocked.
|
|
|
|
Two distinct halts: a REAL daily-loss breach (``_halted``) is permanent
|
|
for the session so a bounce can't re-open the floodgates; an equity-read
|
|
outage (``_data_halt``) blocks WHILE unreadable but recovers on the next
|
|
good read — a transient DNS/network blip must not end the trading day.
|
|
"""
|
|
if self._halted or self._session_closed:
|
|
return True
|
|
max_loss = abs(float(self.settings.max_daily_loss_pct or 0.0))
|
|
if max_loss <= 0:
|
|
return False
|
|
client = getattr(self.executor, "client", None)
|
|
if client is None:
|
|
return False
|
|
try:
|
|
acct = client.get_account()
|
|
equity_raw = acct.get("equity")
|
|
if equity_raw is None:
|
|
raise ValueError("account 'equity' field missing")
|
|
equity = float(equity_raw)
|
|
baseline = float(acct.get("last_equity") or equity)
|
|
# good read -> clear the transient data-halt and resume
|
|
if self._data_halt or self._equity_read_failures:
|
|
if self._data_halt:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message="Equity readable again — resuming new entries",
|
|
rule_type="equity_recovered", rule_value=0.0,
|
|
current_state={"equity": equity},
|
|
action_taken="resumed_new_entries",
|
|
)
|
|
)
|
|
self._data_halt = False
|
|
self._equity_read_failures = 0
|
|
except Exception as exc: # noqa: BLE001
|
|
self._equity_read_failures += 1
|
|
logger.warning(
|
|
"equity read failed (%d consecutive): %s",
|
|
self._equity_read_failures, exc,
|
|
)
|
|
# PAUSE (recoverable): block new entries while equity is unreadable,
|
|
# rather than silently disabling the most important safety control —
|
|
# but DO NOT permanently halt the session for a transient outage.
|
|
if self._equity_read_failures >= self._equity_fail_limit and not self._data_halt:
|
|
self._data_halt = True
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(
|
|
f"Equity unreadable {self._equity_read_failures}x — "
|
|
"pausing new entries until it recovers"
|
|
),
|
|
rule_type="equity_unreadable",
|
|
rule_value=float(self._equity_read_failures),
|
|
current_state={"failures": self._equity_read_failures},
|
|
action_taken="paused_new_entries",
|
|
)
|
|
)
|
|
return self._data_halt
|
|
if baseline <= 0:
|
|
return False
|
|
pnl_pct = (equity - baseline) / baseline
|
|
if pnl_pct <= -max_loss:
|
|
self._halted = True
|
|
flat = (
|
|
self._flatten_all("daily_loss_circuit_breaker")
|
|
if self.settings.flatten_on_breach
|
|
else {"closed_positions": [], "cancelled_entries": 0}
|
|
)
|
|
extra = ""
|
|
action = "halted_new_entries"
|
|
if self.settings.flatten_on_breach:
|
|
n_closed = len(flat["closed_positions"])
|
|
n_cancel = flat["cancelled_entries"]
|
|
flat_errs = flat.get("errors") or []
|
|
extra = (
|
|
f" — flattened {n_closed} position(s), "
|
|
f"cancelled {n_cancel} unfilled entr{'y' if n_cancel == 1 else 'ies'}"
|
|
)
|
|
if flat_errs:
|
|
extra += f"; FLATTEN INCOMPLETE: {flat_errs}"
|
|
action = "halted_flatten_incomplete" if flat_errs else "halted_and_flattened"
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(
|
|
f"Daily-loss circuit breaker tripped: {pnl_pct:+.2%} "
|
|
f"(limit -{max_loss:.0%}) — halting new entries{extra}"
|
|
),
|
|
rule_type="daily_loss",
|
|
rule_value=max_loss,
|
|
current_state={
|
|
"equity": equity,
|
|
"baseline_equity": baseline,
|
|
"pnl_pct": round(pnl_pct, 4),
|
|
"flatten": flat,
|
|
},
|
|
action_taken=action,
|
|
)
|
|
)
|
|
return self._halted
|
|
|
|
@_locked
|
|
def _dilution_vetoed(self, symbol: str) -> dict | None:
|
|
"""If the gated catalyst veto is on AND the LLM advisory flags a confirmed
|
|
dilutive offering at/above the conviction floor, return the advisory (so
|
|
the caller can emit a risk event + skip). Otherwise None. Never raises —
|
|
a missing/odd advisory simply doesn't veto."""
|
|
if not (self.settings.catalyst_veto_enabled and self.catalyst_provider):
|
|
return None
|
|
try:
|
|
adv = self.catalyst_provider(symbol)
|
|
except Exception: # noqa: BLE001 — advisory is best-effort, never blocks on its own fault
|
|
return None
|
|
if not adv or not adv.get("is_dilutive"):
|
|
return None
|
|
try:
|
|
conviction = float(adv.get("conviction") or 0.0)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if conviction >= self.settings.catalyst_veto_min_conviction:
|
|
return adv
|
|
return None
|
|
|
|
def _emit_dilution_veto(self, symbol: str, adv: dict) -> None:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"{symbol}: entry blocked — confirmed dilutive catalyst",
|
|
rule_type="catalyst_dilution_veto",
|
|
rule_value=float(adv.get("conviction") or 0.0),
|
|
current_state={
|
|
"symbol": symbol,
|
|
"catalyst_type": adv.get("catalyst_type"),
|
|
"sentiment": adv.get("sentiment"),
|
|
"rationale": adv.get("rationale"),
|
|
},
|
|
action_taken="blocked_dilutive_offering",
|
|
)
|
|
)
|
|
|
|
def _confirm_minute_of(self, ts) -> float | None:
|
|
"""Minutes after the 09:30 session open that `ts` (a ready/confirmation time)
|
|
landed. None if unparseable (fail-open — never block on missing data)."""
|
|
if ts is None:
|
|
return None
|
|
try:
|
|
if isinstance(ts, str):
|
|
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
if ts.tzinfo is not None: # store times are local-naive; normalize
|
|
ts = ts.astimezone().replace(tzinfo=None)
|
|
open_dt = ts.replace(hour=9, minute=30, second=0, microsecond=0)
|
|
return (ts - open_dt).total_seconds() / 60.0
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
|
|
def _late_confirmation(self, ts) -> bool:
|
|
"""True when the confirmation-speed gate is ON and `ts` confirmed too late."""
|
|
limit = self.settings.entry_confirm_by_minute
|
|
if limit <= 0:
|
|
return False
|
|
m = self._confirm_minute_of(ts)
|
|
return m is not None and m > limit
|
|
|
|
def _population_reject(self, gap_pct, cum_volume, price=None, rvol=None) -> str | None:
|
|
"""Reason string when the signal is OUTSIDE the +EV population (gap band,
|
|
volume floor, price band); None when tradeable. gap_pct MUST be the STATIC
|
|
overnight gap (session open vs prev close) — the 2026-07-08 audit found both
|
|
paths passing dynamic/intraday gaps, letting non-gappers (TVRD 6%, SKIN 2.2%,
|
|
JLHL 4.3%) and dead-band names (SEER 38.6%, IOTR 40.7%) into the book.
|
|
Fail-open: missing values never block (except the dead band, which requires
|
|
a VERIFIED squeeze to re-admit)."""
|
|
if self.settings.min_entry_gap_pct > 0 and gap_pct is not None:
|
|
try:
|
|
g = float(gap_pct)
|
|
if g < self.settings.min_entry_gap_pct:
|
|
return f"gap {g:.1%} < {self.settings.min_entry_gap_pct:.0%}"
|
|
# dead band (35-150%): +0.02R at year scale — excluded. Above 150%
|
|
# only VOLUME-VERIFIED squeezes re-enter (real ones ran +0.63R;
|
|
# reverse-split fakes show rvol ~0.01).
|
|
if 0.35 < g < 1.50:
|
|
return f"gap {g:.0%} in dead band 35-150%"
|
|
if g >= 1.50 and (rvol is None or float(rvol) < 5.0):
|
|
return f"gap {g:.0%} squeeze unverified (rvol {rvol})"
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if price is not None:
|
|
try:
|
|
px = float(price)
|
|
if px > 0 and not (1.0 <= px <= 20.0):
|
|
return f"price {px:.2f} outside $1-20 band"
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if self.settings.min_day_volume > 0 and cum_volume is not None:
|
|
try:
|
|
if float(cum_volume) < self.settings.min_day_volume:
|
|
return (f"day volume {float(cum_volume):,.0f} < "
|
|
f"{self.settings.min_day_volume:,.0f}")
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
def request_approvals_for_ready_signals(self) -> list[str]:
|
|
"""Turn fresh ready signals into approval requests. Returns order ids."""
|
|
if not self.settings.enabled:
|
|
return []
|
|
|
|
if self._daily_loss_breach():
|
|
return []
|
|
|
|
if self._open_position_count() >= self.settings.max_concurrent_positions:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message="Max concurrent positions reached — no new entries",
|
|
rule_type="max_concurrent_positions",
|
|
rule_value=float(self.settings.max_concurrent_positions),
|
|
current_state={"open_positions": self._open_position_count()},
|
|
action_taken="skipped_new_entries",
|
|
)
|
|
)
|
|
return []
|
|
|
|
held = self._held_symbols()
|
|
pending = {row["symbol"] for row in query_approval_queue(self.store)}
|
|
signals = query_ready_signals_snapshot(self.store, session_id=self.session_id)
|
|
|
|
created: list[str] = []
|
|
for signal in signals:
|
|
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
|
|
or self._in_cooldown(symbol) # benched after recent backouts
|
|
or symbol in self._exited_today): # already round-tripped today
|
|
continue
|
|
vetoed = self._dilution_vetoed(symbol)
|
|
if vetoed is not None:
|
|
self._emit_dilution_veto(symbol, vetoed)
|
|
self._requested_symbols.add(symbol) # don't re-evaluate every tick
|
|
continue
|
|
entry = signal.get("entry_price")
|
|
stop = signal.get("stop_loss_price")
|
|
if not entry or not stop or stop >= entry:
|
|
continue
|
|
|
|
# CONFIRMATION-SPEED gate — the one backtested +EV filter: only setups
|
|
# whose breakout confirmed EARLY convert (+0.42R by ~bar 10); late
|
|
# confirmations and midday re-triggers are dead (-0.8R). Skip + log.
|
|
if self._late_confirmation(signal.get("timestamp")):
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{symbol} ready too late "
|
|
f"(confirm minute {self._confirm_minute_of(signal.get('timestamp')):.0f}"
|
|
f" > {self.settings.entry_confirm_by_minute}) — skipped"),
|
|
rule_type="late_confirmation",
|
|
rule_value=float(self.settings.entry_confirm_by_minute),
|
|
current_state={"symbol": symbol,
|
|
"ready_at": str(signal.get("timestamp"))},
|
|
action_taken="skipped_entry",
|
|
)
|
|
)
|
|
self._requested_symbols.add(symbol) # don't re-log every tick
|
|
continue
|
|
|
|
# POPULATION gate — the year-long +EV boundary: real gappers with real
|
|
# volume. Fail-open on missing data; skips are logged and visible.
|
|
pop_reason = self._population_reject(
|
|
signal.get("gap_pct"), signal.get("cum_volume"),
|
|
price=signal.get("entry_price"),
|
|
rvol=signal.get("relative_volume"))
|
|
if pop_reason:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"{symbol} outside tradeable population ({pop_reason}) — skipped",
|
|
rule_type="population_below",
|
|
rule_value=float(self.settings.min_entry_gap_pct),
|
|
current_state={"symbol": symbol,
|
|
"gap_pct": signal.get("gap_pct"),
|
|
"cum_volume": signal.get("cum_volume")},
|
|
action_taken="skipped_entry",
|
|
)
|
|
)
|
|
self._requested_symbols.add(symbol) # don't re-log every tick
|
|
continue
|
|
|
|
# VWAP selection gate — the one validated entry-quality signal (above-
|
|
# VWAP breakouts reach +1R ~1.5x as often, n=3,110). Every below-VWAP
|
|
# ready signal is shadow-logged for measurement; the entry is only
|
|
# SKIPPED when require_above_vwap is on. above_vwap is None on signals
|
|
# lacking the field -> fail-open, never block on missing data.
|
|
if signal.get("above_vwap") is False:
|
|
enforced = self.settings.require_above_vwap
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(
|
|
f"{symbol} ready BELOW session VWAP "
|
|
f"(vwap={signal.get('vwap')}, entry={entry}) — "
|
|
f"{'skipped' if enforced else 'shadow-logged'}"
|
|
),
|
|
rule_type="vwap_below",
|
|
rule_value=float(signal.get("vwap") or 0.0),
|
|
current_state={"symbol": symbol, "entry": float(entry),
|
|
"enforced": enforced},
|
|
action_taken="skipped_entry" if enforced else "shadow_logged",
|
|
)
|
|
)
|
|
if enforced:
|
|
self._requested_symbols.add(symbol) # don't re-evaluate every tick
|
|
continue
|
|
|
|
# QUALITY-GRADE gate — trade fewer, higher-quality setups. Shadow-logged
|
|
# always; entries only SKIPPED when min_quality_score > 0. Fail-open: a
|
|
# signal with no quality_score is never blocked.
|
|
qs = signal.get("quality_score")
|
|
if qs is not None and self.settings.min_quality_score > 0 and qs < self.settings.min_quality_score:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{symbol} ready below quality gate "
|
|
f"(score {qs:.2f} < {self.settings.min_quality_score:.2f}) — skipped"),
|
|
rule_type="quality_below",
|
|
rule_value=float(qs),
|
|
current_state={"symbol": symbol, "quality_score": float(qs),
|
|
"min": self.settings.min_quality_score},
|
|
action_taken="skipped_entry",
|
|
)
|
|
)
|
|
self._requested_symbols.add(symbol) # don't re-evaluate every tick
|
|
continue
|
|
|
|
# Anti-chase / halt gates on the LIVE auto path (the entry-path
|
|
# unification — these ran ONLY on the fast trigger path before). The
|
|
# auto path rests a limit AT the trigger, so it can't chase above it
|
|
# (over_extended is a no-op here); the gate that bites is day-extension:
|
|
# don't enter a name whose breakout level is already parabolic vs the
|
|
# session open. halted=False (a resting limit won't fill mid-halt).
|
|
# Fail-open: a missing day_open skips only the day-extension check.
|
|
if self.settings.unified_entry:
|
|
entry_f0 = float(entry)
|
|
skip = self._anti_chase_skip(entry_f0, entry_f0,
|
|
signal.get("day_open"), False)
|
|
if skip is not None:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{symbol} entry blocked: {skip} "
|
|
f"(entry={entry}, day_open={signal.get('day_open')})"),
|
|
rule_type=skip,
|
|
rule_value=float(signal.get("day_open") or 0.0),
|
|
current_state={"symbol": symbol, "entry": entry_f0,
|
|
"day_open": signal.get("day_open")},
|
|
action_taken="skipped_entry",
|
|
)
|
|
)
|
|
self._requested_symbols.add(symbol) # don't re-evaluate every tick
|
|
continue
|
|
|
|
eq = self._current_equity()
|
|
# cap this entry to BOTH the per-position limit AND the portfolio's
|
|
# remaining gross-notional budget (so the book can't pile into ~100%).
|
|
pos_cap = min(eq * self.settings.max_position_pct,
|
|
self._remaining_gross_budget(eq))
|
|
# Liquidity cap (unification): on the auto path too, cap shares to a
|
|
# fraction of the name's cumulative volume so a thin cheap name can't be
|
|
# oversized into heavy slippage (the VTAK-style stop that filled far past
|
|
# its level). Fail-open: no cum_volume in the signal -> no cap.
|
|
liq = self.settings.liquidity_max_volume_pct
|
|
cum_vol = signal.get("cum_volume") or 0.0
|
|
max_shares = (int(liq * float(cum_vol))
|
|
if (self.settings.unified_entry and liq > 0 and cum_vol > 0)
|
|
else None)
|
|
sizing = calculate_position_size(
|
|
float(entry),
|
|
float(stop),
|
|
equity=eq,
|
|
config=PositionSizingConfig(
|
|
risk_per_trade_pct=self.settings.risk_per_trade_pct,
|
|
default_equity=self.settings.default_equity,
|
|
),
|
|
max_position_value=pos_cap,
|
|
max_shares=max_shares,
|
|
max_risk_dollars=self.settings.max_risk_dollars,
|
|
)
|
|
if sizing.position_size <= 0:
|
|
continue
|
|
|
|
entry_f = float(entry)
|
|
stop_f = float(stop)
|
|
risk_per_share = entry_f - stop_f
|
|
# FILL MODEL (unification): rest a limit AT the level (default), or place
|
|
# a marketable limit a hair above the trigger so it fills as price breaks
|
|
# UP through it (the fast path's runner-catching behaviour). The limit
|
|
# caps the fill price either way, so a gap-through can't be chased. R
|
|
# (stop/target) is measured from the level (entry_f), not the limit.
|
|
limit_price = (
|
|
round(entry_f * (1.0 + self.settings.trigger_slippage_pct), 2)
|
|
if self.settings.entry_fill_model == "marketable" else entry_f
|
|
)
|
|
request = ExecutionRequest(
|
|
symbol=symbol,
|
|
side="buy",
|
|
quantity=sizing.position_size,
|
|
entry_price=limit_price,
|
|
stop_loss_price=stop_f,
|
|
take_profit_price=round(
|
|
entry_f + self.settings.reward_multiple * risk_per_share, 2
|
|
),
|
|
order_type=self.settings.entry_order_type,
|
|
)
|
|
approval_mode = "auto" if self.settings.auto_approve else "manual"
|
|
self.store.emit(
|
|
OrderApprovalRequestedEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(
|
|
f"Approval requested ({approval_mode}): buy "
|
|
f"{request.quantity} {symbol} @ ~{entry} stop {stop}"
|
|
),
|
|
order_id=request.order_id,
|
|
symbol=symbol,
|
|
requested_by="trading_execution",
|
|
approval_mode=approval_mode,
|
|
execution_mode=self.trading_mode.execution_mode,
|
|
execution_request=request.to_payload(),
|
|
)
|
|
)
|
|
self._requested_symbols.add(symbol)
|
|
created.append(request.order_id)
|
|
logger.info("approval requested for %s (%s)", symbol, request.order_id)
|
|
|
|
return created
|
|
|
|
@_locked
|
|
def approve_order(
|
|
self, order_id: str, approved_by: str = "dashboard", notes: str | None = None
|
|
) -> dict:
|
|
"""Approve a pending order and execute it."""
|
|
entry = self._find_pending(order_id)
|
|
if entry is None:
|
|
return {"ok": False, "error": f"order {order_id} not pending"}
|
|
request = ExecutionRequest.from_payload(entry["execution_request"])
|
|
# Per-day fresh-entry cap. Live entries flow through THIS auto-approval
|
|
# path, so the cap must be enforced here (the fast submit_breakout_now
|
|
# path shares the same self._fresh_entries counter). Manual dashboard
|
|
# approvals are a human override and are never capped. Reject (not skip)
|
|
# so a capped order leaves the pending queue instead of re-skipping every
|
|
# tick.
|
|
if approved_by == "auto" and self.settings.max_fresh_entries_per_day > 0:
|
|
today = self._now().date().isoformat()
|
|
if self._fresh_entries.get("date") != today:
|
|
self._fresh_entries = {"date": today, "n": 0}
|
|
if self._fresh_entries["n"] >= self.settings.max_fresh_entries_per_day:
|
|
return self.reject_order(
|
|
order_id, rejected_by="auto", reason="daily_entry_cap"
|
|
)
|
|
self.store.emit(
|
|
OrderApprovedEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"Order {order_id} approved by {approved_by}",
|
|
order_id=order_id,
|
|
symbol=request.symbol,
|
|
approved_by=approved_by,
|
|
approval_notes=notes,
|
|
)
|
|
)
|
|
result = self.executor.execute(request)
|
|
# If the entry didn't fill immediately (a resting limit at the trigger),
|
|
# arm it so expire_stale_entries() can back out on timeout or a break
|
|
# below the entry. Market orders that fill on submit are never armed.
|
|
# Only arm genuinely OPEN resting entries — never a filled, partial, or
|
|
# rejected order (a rejected entry would otherwise occupy a position slot
|
|
# forever and a partial would be backed out while real shares are held).
|
|
if (
|
|
result.ok
|
|
and result.status in {"new", "accepted", "pending_new", "held", "accepted_for_bidding"}
|
|
and request.entry_price
|
|
):
|
|
self._armed[order_id] = {
|
|
"symbol": request.symbol,
|
|
"entry_price": float(request.entry_price),
|
|
"broker_order_id": result.broker_order_id,
|
|
"armed_at": self._now(),
|
|
"checks": 0,
|
|
}
|
|
if (
|
|
approved_by == "auto"
|
|
and result.ok
|
|
and self.settings.max_fresh_entries_per_day > 0
|
|
and isinstance(self._fresh_entries.get("n"), int)
|
|
):
|
|
self._fresh_entries["n"] += 1 # count toward the per-day cap
|
|
if order_id in self._armed: # a resting limit -> free the slot if it backs out
|
|
self._armed[order_id]["counted"] = True
|
|
return {
|
|
"ok": result.ok,
|
|
"order_id": order_id,
|
|
"broker_order_id": result.broker_order_id,
|
|
"status": result.status,
|
|
"error": result.error,
|
|
}
|
|
|
|
def _anti_chase_skip(self, entry_ref: float, trigger: float,
|
|
day_open: float | None, halted: bool) -> str | None:
|
|
"""Shared anti-chase / halt entry gates, used by BOTH the fast trigger path
|
|
and (behind TRADING_UNIFIED_ENTRY) the slow auto path. Returns a skip reason
|
|
or None. Fail-open: a missing/zero day_open skips only the day-extension
|
|
check. For a resting-limit fill (slow path) entry_ref==trigger so
|
|
over_extended is a no-op — day_extension + halt are the gates that bite."""
|
|
ext_max = self.settings.extension_max_pct
|
|
if (ext_max > 0 and trigger and float(trigger) > 0
|
|
and entry_ref > float(trigger) * (1.0 + ext_max)):
|
|
return "over_extended"
|
|
# Day-extension ceiling: the stock is already parabolic on the DAY (far
|
|
# above the session open) — chasing that is the "+100% then halts down"
|
|
# trap even when the trigger break itself looks clean.
|
|
day_max = self.settings.day_extension_max_pct
|
|
if (day_max > 0 and day_open is not None and float(day_open) > 0
|
|
and entry_ref > float(day_open) * (1.0 + day_max)):
|
|
return "over_extended_day"
|
|
# Halt guard: you can't exit during a trading halt and it gaps through the
|
|
# stop on resume — don't enter a name flagged as halted.
|
|
if halted and self.settings.halt_guard_enabled:
|
|
return "halted_symbol"
|
|
return None
|
|
|
|
@_locked
|
|
def submit_breakout_now(
|
|
self,
|
|
symbol: str,
|
|
trigger: float,
|
|
stop: float,
|
|
last_price: float | None = None,
|
|
reason: str = "orb_live_break",
|
|
cum_volume: float = 0.0,
|
|
halted: bool = False,
|
|
day_open: float | None = None,
|
|
rank: int = 0,
|
|
gap_pct: float | None = None,
|
|
rvol: float | None = None,
|
|
price: float | None = None,
|
|
) -> dict:
|
|
"""Fire a breakout entry immediately on a LIVE price cross.
|
|
|
|
The disciplined-but-fast counterpart to the watcher->approval cadence:
|
|
used by the armed-trigger loop when price crosses a pre-computed
|
|
opening-range high. Runs the SAME risk gates as the slow path (daily-loss
|
|
breaker, max-concurrent, per-symbol dedup, 1%-risk sizing), but submits a
|
|
*marketable* limit (capped a hair above the trigger) so it fills on a
|
|
runner instead of resting at the level. Returns a result dict; ``skipped``
|
|
explains a no-op (the symbol can simply try again next tick).
|
|
"""
|
|
if not self.settings.enabled:
|
|
return {"ok": False, "skipped": "disabled"}
|
|
if self._daily_loss_breach():
|
|
return {"ok": False, "skipped": "halted"}
|
|
if self._open_position_count() >= self.settings.max_concurrent_positions:
|
|
return {"ok": False, "skipped": "max_positions"}
|
|
# CONFIRMATION-SPEED gate (same as the auto path): a live trigger fires in
|
|
# near-real-time, so now() IS the confirmation time. Late breaks are dead.
|
|
if self._late_confirmation(self._now()):
|
|
return {"ok": False, "skipped": "late_confirmation"}
|
|
# POPULATION gate (same as the auto path): the year-long +EV boundary
|
|
if self._population_reject(gap_pct, cum_volume or None,
|
|
price=price if price else last_price, rvol=rvol):
|
|
return {"ok": False, "skipped": "population_below"}
|
|
|
|
# CONCENTRATE-BY-RANK: only the top-N armed names may enter, and risk is
|
|
# scaled down by rank. Stops the equal-size spray across mediocre gappers
|
|
# (the human's edge is concentration). rank_factor 0 => outside the top-N.
|
|
rank_factor = rank_risk_factor(rank, self.settings.concentrate_top_n)
|
|
if rank_factor <= 0.0:
|
|
return {"ok": False, "skipped": "rank_concentration"}
|
|
# hard per-DAY fresh-entry cap (a day-trader takes a few good shots, not 12)
|
|
cap = self.settings.max_fresh_entries_per_day
|
|
if cap > 0:
|
|
today = self._now().date().isoformat() if hasattr(self._now(), "date") else None
|
|
if today is not None:
|
|
if self._fresh_entries.get("date") != today:
|
|
self._fresh_entries = {"date": today, "n": 0}
|
|
if self._fresh_entries["n"] >= cap:
|
|
return {"ok": False, "skipped": "daily_entry_cap"}
|
|
|
|
# per-symbol dedup shared with the slow path: never double-enter a name
|
|
# that's already held, pending approval, or requested this session.
|
|
held = self._held_symbols()
|
|
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"}
|
|
if symbol in self._exited_today: # already round-tripped today — no re-entry
|
|
return {"ok": False, "skipped": "reentry_blocked"}
|
|
vetoed = self._dilution_vetoed(symbol) # gated catalyst dilution veto
|
|
if vetoed is not None:
|
|
self._emit_dilution_veto(symbol, vetoed)
|
|
return {"ok": False, "skipped": "dilution_veto"}
|
|
|
|
# Use last_price only when it's a real positive tick. `last_price or
|
|
# trigger` would coerce a 0.0/garbage price to the trigger — silently
|
|
# skipping the extension guard AND sizing on a phantom price.
|
|
if last_price is not None and float(last_price) <= 0:
|
|
return {"ok": False, "skipped": "bad_price"}
|
|
entry_ref = float(last_price) if last_price is not None else float(trigger)
|
|
stop_f = float(stop)
|
|
trigger_f = float(trigger)
|
|
if stop_f >= entry_ref or trigger_f <= 0:
|
|
return {"ok": False, "skipped": "bad_geometry"}
|
|
|
|
# Anti-chase ceiling: distance of the entry price ABOVE the breakout
|
|
# trigger (ORB high). A clean break crosses the level smoothly (~0%) and
|
|
# passes; a violent gap-THROUGH — an intraday parabolic, or a halt
|
|
# reopening past the level — lands far above it, and chasing that buys
|
|
# the top tick that reverses/halts down. (Overnight +100% gappers are
|
|
# already excluded upstream by the trigger book's gap_max; this catches
|
|
# the intraday chase / gap-through that gap_max can't see.)
|
|
skip = self._anti_chase_skip(entry_ref, trigger_f, day_open, halted)
|
|
if skip is not None:
|
|
return {"ok": False, "skipped": skip}
|
|
|
|
eq = self._current_equity()
|
|
liq = self.settings.liquidity_max_volume_pct
|
|
max_shares = int(liq * cum_volume) if (liq > 0 and cum_volume > 0) else None
|
|
# per-position cap AND remaining portfolio gross-notional budget
|
|
pos_cap = min(eq * self.settings.max_position_pct,
|
|
self._remaining_gross_budget(eq))
|
|
if pos_cap <= 0:
|
|
return {"ok": False, "skipped": "gross_notional_cap"}
|
|
# scale the dollar risk by rank (rank-1 full, rank-2+ half) — both the %
|
|
# budget and the hard $ cap scale together so the result scales by the factor
|
|
sizing = calculate_position_size(
|
|
entry_ref,
|
|
stop_f,
|
|
equity=eq,
|
|
config=PositionSizingConfig(
|
|
risk_per_trade_pct=self.settings.risk_per_trade_pct * rank_factor,
|
|
default_equity=self.settings.default_equity,
|
|
),
|
|
max_position_value=pos_cap,
|
|
max_shares=max_shares,
|
|
max_risk_dollars=self.settings.max_risk_dollars * rank_factor,
|
|
)
|
|
if sizing.position_size <= 0:
|
|
return {"ok": False, "skipped": "zero_size"}
|
|
|
|
# marketable limit: fill now (price is already at/above the trigger), but
|
|
# cap how far above we'll chase so a gap-through doesn't pay any price.
|
|
limit_price = round(
|
|
max(entry_ref, trigger_f) * (1.0 + self.settings.trigger_slippage_pct), 2
|
|
)
|
|
risk_per_share = entry_ref - stop_f
|
|
request = ExecutionRequest(
|
|
symbol=symbol,
|
|
side="buy",
|
|
quantity=sizing.position_size,
|
|
entry_price=limit_price,
|
|
stop_loss_price=stop_f,
|
|
take_profit_price=round(
|
|
entry_ref + self.settings.reward_multiple * risk_per_share, 2
|
|
),
|
|
order_type="limit",
|
|
)
|
|
# audit trail mirrors the slow path: requested(auto) -> approved -> submit
|
|
self.store.emit(
|
|
OrderApprovalRequestedEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(
|
|
f"Live ORB break ({reason}): buy {request.quantity} {symbol} "
|
|
f"@ ~{entry_ref:.2f} (trigger {trigger_f:.2f}, stop {stop_f:.2f})"
|
|
),
|
|
order_id=request.order_id,
|
|
symbol=symbol,
|
|
requested_by="orb_trigger",
|
|
approval_mode="auto",
|
|
execution_mode=self.trading_mode.execution_mode,
|
|
execution_request=request.to_payload(),
|
|
)
|
|
)
|
|
self.store.emit(
|
|
OrderApprovedEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"Order {request.order_id} auto-approved (live ORB break)",
|
|
order_id=request.order_id,
|
|
symbol=symbol,
|
|
approved_by="orb_trigger",
|
|
approval_notes=reason,
|
|
)
|
|
)
|
|
self._requested_symbols.add(symbol)
|
|
result = self.executor.execute(request)
|
|
if (
|
|
result.ok
|
|
and result.status
|
|
in {"new", "accepted", "pending_new", "held", "accepted_for_bidding"}
|
|
and request.entry_price
|
|
):
|
|
self._armed[request.order_id] = {
|
|
"symbol": symbol,
|
|
"entry_price": float(request.entry_price),
|
|
"broker_order_id": result.broker_order_id,
|
|
"armed_at": self._now(),
|
|
"checks": 0,
|
|
}
|
|
if result.ok and self.settings.max_fresh_entries_per_day > 0:
|
|
if isinstance(self._fresh_entries.get("n"), int):
|
|
self._fresh_entries["n"] += 1 # count toward the per-day cap
|
|
if request.order_id in self._armed:
|
|
self._armed[request.order_id]["counted"] = True
|
|
logger.info(
|
|
"live ORB break %s qty=%s -> ok=%s status=%s rank=%s",
|
|
symbol, request.quantity, result.ok, result.status, rank,
|
|
)
|
|
return {
|
|
"ok": result.ok,
|
|
"order_id": request.order_id,
|
|
"broker_order_id": result.broker_order_id,
|
|
"status": result.status,
|
|
"error": result.error,
|
|
"quantity": sizing.position_size,
|
|
"entry": limit_price,
|
|
"stop": stop_f,
|
|
}
|
|
|
|
@_locked
|
|
def reject_order(
|
|
self, order_id: str, rejected_by: str = "dashboard", reason: str = "manual"
|
|
) -> dict:
|
|
entry = self._find_pending(order_id)
|
|
if entry is None:
|
|
return {"ok": False, "error": f"order {order_id} not pending"}
|
|
self.store.emit(
|
|
OrderRejectedEvent(
|
|
timestamp=datetime.now(),
|
|
mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"Order {order_id} rejected by {rejected_by}: {reason}",
|
|
order_id=order_id,
|
|
symbol=entry.get("symbol") or "",
|
|
rejected_by=rejected_by,
|
|
rejection_reason=reason,
|
|
)
|
|
)
|
|
# allow the symbol to re-signal later
|
|
self._requested_symbols.discard(entry.get("symbol") or "")
|
|
# Tag the result so callers can tell a rejection apart from a real execution
|
|
# (both carry ok=True). Without this, process_auto_approvals()'s list counted
|
|
# cap-rejections as executions, so a tripped daily-entry cap masqueraded as
|
|
# `auto_executed=1` in the loop log with no matching broker order.
|
|
return {"ok": True, "order_id": order_id, "rejected": True, "reason": reason}
|
|
|
|
@_locked
|
|
def process_auto_approvals(self) -> list[dict]:
|
|
"""Auto-approve any pending requests marked approval_mode=auto."""
|
|
results = []
|
|
for entry in query_approval_queue(self.store):
|
|
if entry.get("approval_mode") == "auto":
|
|
results.append(
|
|
self.approve_order(entry["order_id"], approved_by="auto")
|
|
)
|
|
return results
|
|
|
|
@_locked
|
|
def submit_exit_order(self, symbol: str, reason: str = "manual_exit") -> dict:
|
|
"""Close an open broker position for symbol with a market sell."""
|
|
held = self._held_symbols()
|
|
quantity = None
|
|
snapshots = query_account_positions_snapshot(
|
|
self.store, broker_name=self.executor.broker_name
|
|
)
|
|
if snapshots:
|
|
for p in snapshots[-1].get("positions") or []:
|
|
if str(p.get("symbol")) == symbol:
|
|
quantity = int(abs(float(p.get("quantity") or 0)))
|
|
if symbol not in held or not quantity:
|
|
return {"ok": False, "error": f"no open position for {symbol}"}
|
|
# Prefer close_position: closes the FULL position regardless of long/short
|
|
# sign and fractional qty. A plain 'sell' of abs(qty) would DOUBLE a short
|
|
# position, and int-truncation would leave residual shares un-flat.
|
|
client = getattr(self.executor, "client", None)
|
|
if client is not None and hasattr(client, "close_position"):
|
|
try:
|
|
raw = client.close_position(symbol) or {}
|
|
return {
|
|
"ok": True,
|
|
"order_id": str(raw.get("id") or f"close-{symbol}"),
|
|
"broker_order_id": raw.get("id"),
|
|
"status": raw.get("status") or "closing",
|
|
"error": None,
|
|
"reason": reason,
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"ok": False, "error": str(exc), "reason": reason}
|
|
request = ExecutionRequest(symbol=symbol, side="sell", quantity=quantity)
|
|
result = self.executor.execute(request)
|
|
return {
|
|
"ok": result.ok,
|
|
"order_id": request.order_id,
|
|
"broker_order_id": result.broker_order_id,
|
|
"status": result.status,
|
|
"error": result.error,
|
|
"reason": reason,
|
|
}
|
|
|
|
@_locked
|
|
def close_session(self, reason: str = "eod_flatten") -> dict:
|
|
"""End-of-day: block new entries and flatten the book.
|
|
|
|
A day-trading strategy shouldn't carry overnight gap risk, so at the
|
|
configured time the loop calls this to cancel resting entries, market-
|
|
close every open position (which also cancels its bracket legs), and
|
|
halt new entries for the rest of the session.
|
|
"""
|
|
self._session_closed = True
|
|
result = self._flatten_all(reason)
|
|
# belt-and-suspenders: close any broker position the store snapshot missed
|
|
client = getattr(self.executor, "client", None)
|
|
if client is not None and hasattr(client, "get_positions"):
|
|
try:
|
|
for p in client.get_positions():
|
|
sym = p.get("symbol")
|
|
if sym and sym not in result["closed_positions"]:
|
|
try:
|
|
self._release_and_close(client, sym)
|
|
result["closed_positions"].append(sym)
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"close {sym}: {exc}")
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"positions: {exc}")
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode, correlation_id=self.session_id,
|
|
message=(f"End-of-day flatten ({reason}): closed "
|
|
f"{len(result['closed_positions'])} position(s), "
|
|
f"cancelled {result['cancelled_entries']} entr"
|
|
f"{'y' if result['cancelled_entries'] == 1 else 'ies'}"
|
|
+ (f"; ERRORS {result['errors']}" if result['errors'] else "")),
|
|
rule_type="eod_flatten", rule_value=0.0,
|
|
current_state=result, action_taken="closed_session",
|
|
)
|
|
)
|
|
return result
|
|
|
|
def flatten_overnight_carries(self, today_et, reason: str = "open_catchup") -> dict:
|
|
"""Catch-up flatten: close positions CARRIED from a prior session.
|
|
|
|
A day-trading book should never hold overnight, but a missed/failed EOD
|
|
flatten (loop down, or the held-qty 403 before that was fixed) can leave a
|
|
stale carry bleeding for days (ATPC over the 4-day Juneteenth gap). On
|
|
each session this closes any position not entered today — WITHOUT halting
|
|
the session (unlike ``close_session``); today's fresh names are untouched.
|
|
Best-effort: errors are collected, not raised. Returns
|
|
{carries, closed, errors}.
|
|
"""
|
|
result: dict = {"carries": [], "closed": [], "errors": []}
|
|
client = getattr(self.executor, "client", None)
|
|
if client is None or not hasattr(client, "close_position"):
|
|
return result
|
|
try:
|
|
positions = client.get_positions()
|
|
open_syms = {p.get("symbol") for p in positions
|
|
if abs(float(p.get("qty") or 0)) > 0}
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"positions: {exc}")
|
|
return result
|
|
if not open_syms:
|
|
return result
|
|
try:
|
|
orders = client.get_orders(status="all", limit=500, nested=True,
|
|
symbols=sorted(open_syms))
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"orders: {exc}")
|
|
return result
|
|
buy_fills = buy_fills_from_orders(orders)
|
|
carries = find_overnight_carries(open_syms, buy_fills, today_et)
|
|
result["carries"] = carries
|
|
for symbol in carries:
|
|
try:
|
|
self._release_and_close(client, symbol)
|
|
result["closed"].append(symbol)
|
|
except Exception as exc: # noqa: BLE001
|
|
result["errors"].append(f"close {symbol}: {exc}")
|
|
if carries:
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode, correlation_id=self.session_id,
|
|
message=(f"Open catch-up flatten ({reason}): carried {carries}; "
|
|
f"closed {result['closed']}"
|
|
+ (f"; ERRORS {result['errors']}" if result['errors'] else "")),
|
|
rule_type="open_catchup_flatten", rule_value=0.0,
|
|
current_state=result, action_taken="flatten_carries",
|
|
)
|
|
)
|
|
return result
|
|
|
|
def _filled_order_ids(self) -> set[str]:
|
|
ids: set[str] = set()
|
|
for e in self.store.query_events(event_type="order_filled", limit=None):
|
|
payload = json.loads(e.get("payload_json", "{}"))
|
|
if payload.get("order_id"):
|
|
ids.add(str(payload["order_id"]))
|
|
return ids
|
|
|
|
def _cancelled_order_ids(self) -> set[str]:
|
|
ids: set[str] = set()
|
|
for e in self.store.query_events(event_type="order_cancelled", limit=None):
|
|
payload = json.loads(e.get("payload_json", "{}"))
|
|
if payload.get("order_id"):
|
|
ids.add(str(payload["order_id"]))
|
|
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 _broker_positions(self) -> list[dict] | None:
|
|
"""Fresh broker positions (full dicts), or None if unreadable."""
|
|
client = getattr(self.executor, "client", None)
|
|
if client is None or not hasattr(client, "get_positions"):
|
|
return None
|
|
try:
|
|
return client.get_positions(fresh=True) or []
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("broker positions unreadable in re-entry guard: %s", exc)
|
|
return None
|
|
|
|
def _note_position_exits(self) -> None:
|
|
"""Detect names whose position closed since last pass and bench the ones
|
|
that exited at a real LOSS from re-entry this session — re-buying a name
|
|
that just stopped out is throwing good money after bad. A name that
|
|
scratched or won is left alone (a quick re-entry can be the right call)."""
|
|
if not self.settings.reentry_block_after_exit:
|
|
return
|
|
positions = self._broker_positions()
|
|
if positions is None: # broker unreadable — don't trust a stale snapshot
|
|
return
|
|
held: set[str] = set()
|
|
for p in positions:
|
|
sym = p.get("symbol")
|
|
if not sym:
|
|
continue
|
|
held.add(sym)
|
|
try:
|
|
self._held_ret[sym] = float(p.get("unrealized_plpc") or 0.0)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
thr = abs(float(self.settings.reentry_min_loss_pct or 0.0))
|
|
for sym in (self._prev_held - held):
|
|
last_ret = self._held_ret.pop(sym, 0.0)
|
|
if thr > 0 and last_ret >= -thr:
|
|
continue # scratched or won — allow re-entry
|
|
self._exited_today.add(sym)
|
|
self.store.emit(RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{sym} closed at {last_ret*100:+.1f}% — benched from "
|
|
f"re-entry today"),
|
|
rule_type="reentry_block", rule_value=last_ret,
|
|
current_state={"symbol": sym, "exit_return": last_ret},
|
|
action_taken="reentry_blocked",
|
|
))
|
|
self._prev_held = held
|
|
|
|
def _release_cap_slot(self, armed: dict | None) -> None:
|
|
"""Free a per-day cap slot for an entry that consumed one but backed out UNFILLED.
|
|
This makes the cap count FILLS, not submissions: an unfilled/canceled limit no longer
|
|
permanently burns a slot (the 2026-07-01/07-02 bug where 2 phantom entries capped the
|
|
day). Fills keep their slot (their armed dict is popped without calling this)."""
|
|
if (armed and armed.get("counted")
|
|
and isinstance(self._fresh_entries.get("n"), int)
|
|
and self._fresh_entries["n"] > 0):
|
|
self._fresh_entries["n"] -= 1
|
|
|
|
def expire_stale_entries(self) -> list[dict]:
|
|
"""Back out of unfilled entries that timed out or broke their trigger.
|
|
|
|
This is the disciplined other half of auto-arming: once a setup is
|
|
recognized we enter with conviction, but if the fill never comes (the
|
|
move didn't follow through) we don't sit there forever — we cancel on a
|
|
time box or when price falls back through the entry, freeing risk
|
|
budget and the concurrent-position slot for the next setup.
|
|
|
|
Safe to call as often as you like: the price-break check uses the live
|
|
``price_provider`` each call, while the timeout is wall-clock based, so
|
|
running this every few seconds reacts fast without shortening the
|
|
timeout window.
|
|
"""
|
|
# track positions that have CLOSED since last pass (so a stopped-out name
|
|
# is benched from re-entry). Done BEFORE the early-return so it runs every
|
|
# pass, not only when there are armed entries to expire.
|
|
self._note_position_exits()
|
|
if not self._armed:
|
|
return []
|
|
|
|
filled = self._filled_order_ids()
|
|
cancelled = self._cancelled_order_ids()
|
|
# broker truth: never cancel an entry whose position is actually open
|
|
# (cancelling its bracket parent would strip the protective stop/TP).
|
|
# Fall back to the synced snapshot if the broker can't be read.
|
|
broker_held = self._broker_held_symbols()
|
|
if broker_held is None:
|
|
broker_held = self._held_symbols()
|
|
invalidate_pct = self.settings.entry_invalidate_pct
|
|
timeout_bars = self.settings.entry_timeout_bars
|
|
now = self._now()
|
|
actions: list[dict] = []
|
|
|
|
for order_id in list(self._armed):
|
|
armed = self._armed[order_id]
|
|
symbol = armed["symbol"]
|
|
|
|
# already resolved at the broker -> stop tracking
|
|
if order_id in filled:
|
|
self._armed.pop(order_id, None)
|
|
continue
|
|
if order_id in cancelled:
|
|
self._release_cap_slot(self._armed.pop(order_id, None)) # unfilled -> free slot
|
|
self._requested_symbols.discard(symbol)
|
|
continue
|
|
# FILLED at the broker (position open) -> stop tracking, NEVER cancel
|
|
# (this is the naked-stop fix: cancelling here killed the stop leg).
|
|
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
|
|
|
|
# (a) price-break invalidation: traded back below the entry trigger.
|
|
# Uses the live last-trade price, so this reacts tick-by-tick rather
|
|
# than waiting for a bar to close.
|
|
if invalidate_pct >= 0 and self.price_provider is not None:
|
|
try:
|
|
last = self.price_provider(symbol)
|
|
except Exception: # noqa: BLE001
|
|
last = None
|
|
if last is not None:
|
|
threshold = armed["entry_price"] * (1.0 - invalidate_pct)
|
|
if float(last) < threshold:
|
|
reason = (
|
|
f"entry invalidated: {float(last):.4f} < trigger "
|
|
f"{threshold:.4f}"
|
|
)
|
|
|
|
# (b) time box: unfilled for too long (wall-clock minutes ~ bars)
|
|
if reason is None and timeout_bars > 0:
|
|
elapsed_min = (now - armed["armed_at"]).total_seconds() / 60.0
|
|
if elapsed_min >= timeout_bars:
|
|
reason = f"entry timed out: unfilled {elapsed_min:.1f} min"
|
|
|
|
if reason is not None:
|
|
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} [{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._release_cap_slot(armed) # backed out unfilled -> free the cap slot
|
|
self._armed.pop(order_id, None)
|
|
self._requested_symbols.discard(symbol)
|
|
actions.append({"order_id": order_id, "symbol": symbol,
|
|
"reason": reason, "cooldown": bench})
|
|
|
|
return actions
|
|
|
|
@_locked
|
|
def tick(self) -> dict:
|
|
"""One execution pass: expire stale entries, request approvals, auto-execute."""
|
|
backed_out = self.expire_stale_entries()
|
|
requested = self.request_approvals_for_ready_signals()
|
|
auto = self.process_auto_approvals() if self.settings.auto_approve else []
|
|
return {
|
|
"approvals_requested": requested,
|
|
"auto_executed": auto,
|
|
"backed_out": backed_out,
|
|
}
|
|
|
|
# -- helpers --------------------------------------------------------------
|
|
|
|
def _find_pending(self, order_id: str) -> dict | None:
|
|
for entry in query_approval_queue(self.store):
|
|
if str(entry.get("order_id")) == str(order_id):
|
|
return entry
|
|
return None
|