Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed +1.7R, IRE +1.65R by one cent. Two root causes fixed: 1. Slow-path limit rested AT the trigger while price ran 2% past it, and retries reused the same stale price. Marketable model now prices from max(trigger, live last) via price_provider; the anti-chase extension gate gets the live ref too (was an entry_ref==trigger no-op on this path). TRADING_ENTRY_FILL_MODEL flipped to marketable. 2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far / full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy name and its mean is poisoned by reverse-split/prior-squeeze days (BIYA legacy 1.27 vs real activity 481x median). New rvol_tod = cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility and the scanner keep-filter; legacy rvol untouched for validated boundaries (gap-squeeze rvol>=5). Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF) in simulate_exit + manage_live with sweep_exits.py variants for validation before any live enable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2002 lines
101 KiB
Python
2002 lines
101 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, field
|
|
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 (
|
|
EntryGateEvaluatedEvent,
|
|
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.population import PopulationConfig, population_reject
|
|
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
|
|
# REGIME GATE (plan 011) — a session-level metalabel P(our ORB profits today).
|
|
# When enabled, fresh entries are BLOCKED on sessions the walk-forward model
|
|
# scores unfavorable (it sits out bad regimes). Session-level: evaluated ONCE
|
|
# per day and cached. Fail-open: an unfit model (early history), a missing
|
|
# score, or any error never blocks a trade. Off by default; the compose sets it.
|
|
regime_gate_enabled: bool = False
|
|
regime_gate_p_min: float = 0.5
|
|
# 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
|
|
# entries only during the regular session (ET). SIP prints extended-hours
|
|
# trades, so without this gate after-hours price crosses fire real orders
|
|
# (TDTH/SUNE resting entries submitted 18:53-20:09 ET on 2026-07-09).
|
|
# Exits/management are NOT gated — only fresh entries. Dataclass default is
|
|
# OFF (tests build settings directly and run at any hour); from_env defaults
|
|
# it ON, so every live/deployed configuration gets the gate.
|
|
session_only: bool = False
|
|
session_start: str = "09:30"
|
|
session_end: str = "15:55"
|
|
# tradeable price band. Floor raised $1->$2 2026-07-09: the $1-2 bucket ran
|
|
# -0.031R over 609 unbiased year-scale entries (worst bucket; spreads are
|
|
# brutal sub-$2, and the source trader's own cutoff is ~$2).
|
|
price_band_min: float = 2.0
|
|
price_band_max: float = 20.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
|
|
# DECISION RECEIPTS — emit one entry_gate_evaluated event per gate evaluation
|
|
# (pass or reject) so 'why didn't it trade X?' is a query, not a forensic
|
|
# session. High-volume diagnostic (purged after 14 days). Default ON; the
|
|
# dataclass default is OFF so unit tests that build settings directly don't
|
|
# emit unless they opt in.
|
|
gate_receipts: bool = False
|
|
|
|
@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"),
|
|
regime_gate_enabled=flag("TRADING_REGIME_GATE_ENABLED", "0"),
|
|
regime_gate_p_min=_qfloat(values.get("TRADING_REGIME_GATE_P_MIN", "0.5")),
|
|
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")),
|
|
session_only=flag("TRADING_SESSION_ONLY", "1"),
|
|
session_start=values.get("TRADING_SESSION_START", "09:30"),
|
|
session_end=values.get("TRADING_SESSION_END", "15:55"),
|
|
price_band_min=float(values.get("TRADING_PRICE_MIN", "2.0")),
|
|
price_band_max=float(values.get("TRADING_PRICE_MAX", "20.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")
|
|
),
|
|
gate_receipts=flag("TRADING_GATE_RECEIPTS", "1"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class EntryContext:
|
|
"""Everything the shared entry-gate sequence needs about one candidate.
|
|
|
|
Built by each path from its own data source (the slow path from a ready-signal
|
|
snapshot, the fast path from a live trigger), then handed to
|
|
``_evaluate_entry_gates``. Fields the source lacks stay None -> fail-open."""
|
|
|
|
symbol: str
|
|
source: str # "auto" (slow) | "fast"
|
|
entry: float # entry/limit reference level
|
|
stop: float
|
|
trigger: float # anti-chase trigger (slow: == entry_ref)
|
|
entry_ref: float # price used for anti-chase distance
|
|
gap_pct: float | None = None
|
|
price: float | None = None # price for the population band
|
|
cum_volume: float | None = None
|
|
rvol: float | None = None
|
|
above_vwap: bool | None = None
|
|
vwap: float | None = None
|
|
quality_score: float | None = None
|
|
rank: int | None = None
|
|
day_open: float | None = None
|
|
halted: bool = False
|
|
confirm_ts: object = None # ready time (slow) / now() (fast) for late gate
|
|
held: set | None = None # precomputed once-per-tick on the slow path
|
|
pending: set | None = None
|
|
|
|
|
|
@dataclass
|
|
class GateDecision:
|
|
"""First-reject-or-pass outcome of the shared entry-gate sequence."""
|
|
|
|
passed: bool
|
|
reason: str | None = None # reject reason / fast-path `skipped` string
|
|
rank_factor: float = 1.0 # risk scale from concentrate-by-rank
|
|
throttle: bool = False # slow path: bench symbol from re-eval this session
|
|
gates: dict = field(default_factory=dict) # per-gate {value,threshold,outcome} receipt
|
|
|
|
|
|
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] = {}
|
|
# regime gate (plan 011): today's favorable/unfavorable decision, computed
|
|
# ONCE per session and cached ({"date", "favorable"}). None favorable =
|
|
# fail-open (unfit model / error) — never blocks.
|
|
self._regime_today: dict = {"date": None, "favorable": None}
|
|
# 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
|
|
# operator PAUSE (plan 007 command bus): blocks new entries on BOTH the
|
|
# slow auto path and the fast trigger path until a resume command clears
|
|
# it. In-memory + recoverable — unlike _session_closed it does not flatten
|
|
# and a fresh command re-enables entries.
|
|
self._operator_paused = False
|
|
# today's EOD flatten already SUCCEEDED (rebuilt from events on boot) —
|
|
# the daily-restart loop reads this so a post-flatten fresh process
|
|
# neither re-flattens nor re-exits (restart-churn guard)
|
|
self._eod_flatten_ok_today = 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()
|
|
# rebuild the memory-only daily state from today's events so a mid-session
|
|
# restart doesn't reset the entry cap to 0, un-bench stopped-out names, or
|
|
# forget which symbols already have a resting order (re-fire => duplicate
|
|
# order, the 2026-07-06 incident). Best-effort; a fresh DB is fine.
|
|
self._rebuild_daily_state()
|
|
|
|
def _rebuild_daily_state(self) -> None:
|
|
"""Restore per-day counters/benches from today's event stream on boot.
|
|
|
|
ponytail: rebuilds the SLOW-path dedup + cap + bench only. The fast
|
|
trigger book (run_live_paper) is separately memory-only; the standing
|
|
broker-open-orders diff after a restart is still the backstop for a
|
|
resting fast-path order (see momentum-live-loop-architecture memory).
|
|
"""
|
|
try:
|
|
import json as _json
|
|
today = self._now().date()
|
|
since = today.isoformat()
|
|
|
|
def _payload(e):
|
|
raw = e.get("payload") or e.get("payload_json")
|
|
if isinstance(raw, str):
|
|
try:
|
|
return _json.loads(raw)
|
|
except ValueError:
|
|
return {}
|
|
return raw or {}
|
|
|
|
# order_filled carries no side; map fills back to their BUY submit so
|
|
# sell/exit fills don't inflate the fresh-entry cap
|
|
buy_order_ids = set()
|
|
for e in self.store.query_events(event_type="order_submitted", since=since, limit=500):
|
|
p = _payload(e)
|
|
sym, oid = p.get("symbol"), p.get("order_id")
|
|
if sym:
|
|
self._requested_symbols.add(str(sym))
|
|
if oid and str(p.get("side", "buy")).lower() == "buy":
|
|
buy_order_ids.add(str(oid))
|
|
n_fresh = sum(
|
|
1 for e in self.store.query_events(event_type="order_filled", since=since, limit=500)
|
|
if str(_payload(e).get("order_id")) in buy_order_ids)
|
|
self._fresh_entries = {"date": today, "n": n_fresh}
|
|
for e in self.store.query_events(event_type="position_closed", since=since, limit=500):
|
|
p = _payload(e)
|
|
sym = p.get("symbol")
|
|
# bench only names that closed at a LOSS (mirror the live bench rule)
|
|
if sym and float(p.get("realized_pnl") or 0) < 0:
|
|
self._exited_today.add(str(sym))
|
|
# session-closed detection: close_session emits rule_type="eod_flatten".
|
|
# ANY such event today = the EOD window already began, so re-entry
|
|
# stays blocked; a SUCCESSFUL one (no errors) also tells the loop not
|
|
# to re-flatten/re-exit after the daily restart. A FAILED flatten
|
|
# deliberately does NOT set the ok flag — the fresh process retries
|
|
# inside the window exactly as before. limit=None: the eod_flatten
|
|
# event lands LAST in the day and query order is ASC, so a capped
|
|
# query on a busy risk-event day could silently drop it.
|
|
for e in self.store.query_events(event_type="risk_rule_triggered",
|
|
since=since, limit=None):
|
|
p = _payload(e)
|
|
if p.get("rule_type") != "eod_flatten":
|
|
continue
|
|
self._session_closed = True
|
|
if not (p.get("current_state") or {}).get("errors"):
|
|
self._eod_flatten_ok_today = True
|
|
if self._session_closed:
|
|
logger.info("restored session-closed state: EOD flatten already "
|
|
"emitted today (succeeded=%s)", self._eod_flatten_ok_today)
|
|
if n_fresh or self._requested_symbols or self._exited_today:
|
|
logger.info("restored daily state: %d fresh fills, %d requested, %d benched",
|
|
n_fresh, len(self._requested_symbols), len(self._exited_today))
|
|
except Exception as exc: # noqa: BLE001 — a rebuild failure must not block boot
|
|
logger.warning("daily-state rebuild skipped: %s", exc)
|
|
|
|
def eod_flatten_done_today(self) -> bool:
|
|
"""True when today's EOD flatten already SUCCEEDED (from the event stream
|
|
at boot). The loop seeds its _eod_done flag from this so a post-flatten
|
|
restart neither re-flattens an already-flat book nor exit-churns."""
|
|
return self._eod_flatten_ok_today
|
|
|
|
# -- 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": []}
|
|
client = getattr(self.executor, "client", None)
|
|
# broker truth up front: an entry that FILLED in the sync gap is still in
|
|
# _armed; cancelling its bracket parent would strip the stop/TP legs and
|
|
# leave the position naked. Never cancel a name the broker holds — flatten
|
|
# it in step 2 instead. None => positions unreadable, stay conservative.
|
|
broker_held = self._broker_held_symbols() or set()
|
|
# 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", "")
|
|
if symbol in broker_held:
|
|
continue # filled — leave the bracket intact; step 2/3 closes it
|
|
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 from the store snapshot
|
|
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}")
|
|
# 3) belt-and-suspenders: close any BROKER position the store snapshot
|
|
# missed (filled within the ~60s sync lag). Was in close_session only, so
|
|
# the daily-loss breaker flatten skipped it — moved here so EVERY caller
|
|
# (breaker + EOD) gets it. This is the naked-through-breaker fix.
|
|
if client is not None and hasattr(client, "get_positions"):
|
|
try:
|
|
for p in client.get_positions(fresh=True) or []:
|
|
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}")
|
|
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 _outside_session(self, now_et=None) -> str | None:
|
|
"""Reason string when fresh entries are barred (outside the regular
|
|
session, ET); None while entries are allowed. Weekends always barred."""
|
|
if not self.settings.session_only:
|
|
return None
|
|
try:
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
now = now_et or datetime.now(ZoneInfo("America/New_York"))
|
|
if now.weekday() >= 5:
|
|
return f"weekend ({now:%a})"
|
|
hm = now.strftime("%H:%M")
|
|
if not (self.settings.session_start <= hm <= self.settings.session_end):
|
|
return (f"{hm} ET outside entry session "
|
|
f"{self.settings.session_start}-{self.settings.session_end}")
|
|
except Exception: # noqa: BLE001 — a broken clock must not block trading
|
|
return None
|
|
return None
|
|
|
|
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).
|
|
|
|
Thin wrapper: the population is defined ONCE in strategy/population.py so
|
|
the tuner, replay, and experiments screen through the identical logic."""
|
|
cfg = PopulationConfig(
|
|
price_min=self.settings.price_band_min,
|
|
price_max=self.settings.price_band_max,
|
|
min_entry_gap_pct=self.settings.min_entry_gap_pct,
|
|
min_day_volume=self.settings.min_day_volume,
|
|
)
|
|
return population_reject(
|
|
cfg, gap_pct=gap_pct, price=price, volume=cum_volume, rvol=rvol)
|
|
|
|
def _regime_reject(self, now_et=None) -> str | None:
|
|
"""Session-level regime gate (plan 011): ``"regime_unfavorable"`` when
|
|
today's walk-forward metalabel scores the session unfavorable, else None.
|
|
The scorer's input (today's premarket fires) is complete once the
|
|
premarket is fully ingested, so a verdict computed at/after 9:50 ET —
|
|
even a fail-open None — is FINAL for the session (the expensive scorer
|
|
never re-runs on the shared entry hot path). A verdict computed EARLIER
|
|
(first ORB entries ~9:35, live bars may still be catching up) is
|
|
PROVISIONAL — even a definite BLOCK — and re-scored every ~5 min until
|
|
a final one lands. FAIL-OPEN by construction: disabled, an unfit model
|
|
(early history / <60 prior sessions), missing data, or ANY exception
|
|
returns None — the gate can only ever SUBTRACT trades on bad-regime
|
|
days, never block on an error."""
|
|
if not self.settings.regime_gate_enabled:
|
|
return None
|
|
try:
|
|
from datetime import datetime, time as dtime
|
|
from zoneinfo import ZoneInfo
|
|
now = now_et or datetime.now(ZoneInfo("America/New_York"))
|
|
today = now.date()
|
|
cache = self._regime_today
|
|
rescore = (cache.get("date") != today
|
|
or (not cache.get("final")
|
|
and (cache.get("at") is None
|
|
or (now - cache["at"]).total_seconds() >= 300)))
|
|
except Exception: # noqa: BLE001 — a broken clock/cache must not block trading
|
|
return None
|
|
if rescore:
|
|
fav = None
|
|
try:
|
|
from research.regime import live_session_favorable
|
|
fav = live_session_favorable(
|
|
self.store.con, today, p_min=self.settings.regime_gate_p_min)
|
|
except Exception as exc: # noqa: BLE001 — a scorer failure NEVER blocks trading
|
|
logger.warning("regime gate scoring failed (fail-open): %s", exc)
|
|
fav = None
|
|
self._regime_today = {"date": today, "favorable": fav, "at": now,
|
|
"final": now.time() >= dtime(9, 50)}
|
|
return "regime_unfavorable" if self._regime_today.get("favorable") is False else None
|
|
|
|
def _evaluate_entry_gates(self, ctx: EntryContext) -> GateDecision:
|
|
"""The ONE ordered entry-gate sequence, shared by the slow auto-approval
|
|
path and the fast trigger path (plan 005). Returns the first reject or a
|
|
pass; each gate body is the one that lived on whichever path owned it.
|
|
|
|
Emission convention (topology, not policy): the slow path's per-gate risk
|
|
events are preserved verbatim, source=="auto"-guarded, so the fast path
|
|
gains no new events — its observable decision stays the returned reason /
|
|
`skipped` string. The dilution veto emits on both paths (as it did today).
|
|
Price-sanity / entry_ref derivation stays in each caller (path-specific
|
|
float coercion from different data sources)."""
|
|
gates: dict = {}
|
|
|
|
def _done(passed, reason=None, throttle=False, rank_factor=1.0):
|
|
decision = GateDecision(passed=passed, reason=reason,
|
|
rank_factor=rank_factor, throttle=throttle,
|
|
gates=gates)
|
|
self._emit_gate_receipt(ctx, decision)
|
|
return decision
|
|
|
|
is_auto = ctx.source == "auto"
|
|
|
|
# SESSION — quiet skip on both paths (outside-session is normal, not a risk event)
|
|
if self._outside_session():
|
|
gates["session"] = {"outcome": "reject"}
|
|
return _done(False, "outside_session", throttle=False)
|
|
gates["session"] = {"outcome": "pass"}
|
|
|
|
# REGIME — session-level metalabel (plan 011): sit out days the walk-forward
|
|
# model scores unfavorable. Quiet skip like SESSION (a normal daily policy
|
|
# call, not a per-name risk anomaly); the gate receipt records it. Definite
|
|
# verdicts cached per day, fail-open retried ~5 min inside _regime_reject.
|
|
if self._regime_reject():
|
|
gates["regime"] = {"value": self._regime_today.get("favorable"), "outcome": "reject"}
|
|
return _done(False, "regime_unfavorable", throttle=False)
|
|
gates["regime"] = {"outcome": "pass"}
|
|
|
|
# CONFIRMATION-SPEED gate — only early-confirming breakouts convert.
|
|
if self._late_confirmation(ctx.confirm_ts):
|
|
gates["late_confirmation"] = {
|
|
"value": self._confirm_minute_of(ctx.confirm_ts),
|
|
"threshold": self.settings.entry_confirm_by_minute, "outcome": "reject"}
|
|
if is_auto:
|
|
self.store.emit(RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{ctx.symbol} ready too late "
|
|
f"(confirm minute {self._confirm_minute_of(ctx.confirm_ts):.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": ctx.symbol, "ready_at": str(ctx.confirm_ts)},
|
|
action_taken="skipped_entry"))
|
|
return _done(False, "late_confirmation", throttle=is_auto)
|
|
gates["late_confirmation"] = {"outcome": "pass"}
|
|
|
|
# POPULATION gate — the year-long +EV boundary (gap band, volume, price).
|
|
pop_reason = self._population_reject(
|
|
ctx.gap_pct, ctx.cum_volume, price=ctx.price, rvol=ctx.rvol)
|
|
if pop_reason:
|
|
gates["population"] = {"value": ctx.gap_pct,
|
|
"threshold": self.settings.min_entry_gap_pct,
|
|
"outcome": "reject"}
|
|
if is_auto:
|
|
self.store.emit(RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"{ctx.symbol} outside tradeable population ({pop_reason}) — skipped",
|
|
rule_type="population_below",
|
|
rule_value=float(self.settings.min_entry_gap_pct),
|
|
current_state={"symbol": ctx.symbol, "gap_pct": ctx.gap_pct,
|
|
"cum_volume": ctx.cum_volume},
|
|
action_taken="skipped_entry"))
|
|
return _done(False, "population_below", throttle=is_auto)
|
|
gates["population"] = {"outcome": "pass"}
|
|
|
|
# VWAP selection gate — below-VWAP is always shadow-logged (slow path);
|
|
# entries are only SKIPPED when require_above_vwap is on. Fail-open on None.
|
|
# CLOSURE (plan 005): the fast path now routes through this gate too — when
|
|
# above_vwap is supplied it enforces (the fast public signature carries no
|
|
# above_vwap today, so in practice it stays fail-open there).
|
|
if ctx.above_vwap is False:
|
|
enforced = self.settings.require_above_vwap
|
|
gates["vwap"] = {"value": ctx.vwap, "threshold": "above_vwap",
|
|
"outcome": "reject" if enforced else "shadow"}
|
|
if is_auto:
|
|
self.store.emit(RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{ctx.symbol} ready BELOW session VWAP "
|
|
f"(vwap={ctx.vwap}, entry={ctx.entry}) — "
|
|
f"{'skipped' if enforced else 'shadow-logged'}"),
|
|
rule_type="vwap_below", rule_value=float(ctx.vwap or 0.0),
|
|
current_state={"symbol": ctx.symbol, "entry": float(ctx.entry),
|
|
"enforced": enforced},
|
|
action_taken="skipped_entry" if enforced else "shadow_logged"))
|
|
if enforced:
|
|
return _done(False, "vwap_below", throttle=True)
|
|
|
|
# QUALITY-GRADE gate — shadow-logged; only SKIPPED when min_quality_score>0.
|
|
qs = ctx.quality_score
|
|
if qs is not None and self.settings.min_quality_score > 0 and qs < self.settings.min_quality_score:
|
|
gates["quality"] = {"value": qs,
|
|
"threshold": self.settings.min_quality_score,
|
|
"outcome": "reject"}
|
|
if is_auto:
|
|
self.store.emit(RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{ctx.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": ctx.symbol, "quality_score": float(qs),
|
|
"min": self.settings.min_quality_score},
|
|
action_taken="skipped_entry"))
|
|
return _done(False, "quality_below", throttle=True)
|
|
|
|
# CONCENTRATE-BY-RANK — only the top-N armed names enter; risk scales down by
|
|
# rank. CLOSURE (plan 005): now runs on the slow path too when a rank is
|
|
# present (rank is None on the slow path unless a rank_map is threaded in ->
|
|
# factor 1.0, no-op). No event on either path (matches the fast path today).
|
|
rank_factor = rank_risk_factor(ctx.rank or 0, self.settings.concentrate_top_n)
|
|
gates["rank"] = {"value": ctx.rank, "threshold": self.settings.concentrate_top_n,
|
|
"outcome": "reject" if rank_factor <= 0.0 else "pass"}
|
|
if rank_factor <= 0.0:
|
|
return _done(False, "rank_concentration", throttle=is_auto,
|
|
rank_factor=rank_factor)
|
|
|
|
# DAILY FRESH-ENTRY CAP — fast path only (the slow path's cap is enforced
|
|
# later, in approve_order, and stays there untouched).
|
|
if ctx.source == "fast":
|
|
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:
|
|
gates["daily_cap"] = {"value": self._fresh_entries["n"],
|
|
"threshold": cap, "outcome": "reject"}
|
|
return _done(False, "daily_entry_cap", throttle=False,
|
|
rank_factor=rank_factor)
|
|
|
|
# DEDUP / BENCH / RE-ENTRY — never double-enter a held/pending/requested name,
|
|
# a benched (recently backed-out) name, or one already round-tripped today.
|
|
held = ctx.held if ctx.held is not None else self._held_symbols()
|
|
pending = (ctx.pending if ctx.pending is not None
|
|
else {row["symbol"] for row in query_approval_queue(self.store)})
|
|
if ctx.symbol in held or ctx.symbol in pending or ctx.symbol in self._requested_symbols:
|
|
return _done(False, "already_active", throttle=False, rank_factor=rank_factor)
|
|
if self._in_cooldown(ctx.symbol):
|
|
return _done(False, "cooldown", throttle=False, rank_factor=rank_factor)
|
|
if ctx.symbol in self._exited_today:
|
|
return _done(False, "reentry_blocked", throttle=False, rank_factor=rank_factor)
|
|
|
|
# DILUTION VETO — emits on BOTH paths (as today).
|
|
vetoed = self._dilution_vetoed(ctx.symbol)
|
|
if vetoed is not None:
|
|
gates["dilution"] = {"value": vetoed.get("conviction"),
|
|
"threshold": self.settings.catalyst_veto_min_conviction,
|
|
"outcome": "reject"}
|
|
self._emit_dilution_veto(ctx.symbol, vetoed)
|
|
return _done(False, "dilution_veto", throttle=is_auto, rank_factor=rank_factor)
|
|
|
|
# ANTI-CHASE / HALT — unconditional on the fast path; behind TRADING_UNIFIED_ENTRY
|
|
# on the slow path (the documented rollback switch; fast stays unconditional).
|
|
if ctx.source == "fast" or self.settings.unified_entry:
|
|
skip = self._anti_chase_skip(ctx.entry_ref, ctx.trigger, ctx.day_open, ctx.halted)
|
|
if skip is not None:
|
|
gates["anti_chase"] = {"value": ctx.entry_ref, "threshold": ctx.day_open,
|
|
"outcome": "reject"}
|
|
if is_auto:
|
|
self.store.emit(RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=(f"{ctx.symbol} entry blocked: {skip} "
|
|
f"(entry={ctx.entry}, day_open={ctx.day_open})"),
|
|
rule_type=skip, rule_value=float(ctx.day_open or 0.0),
|
|
current_state={"symbol": ctx.symbol, "entry": ctx.entry_ref,
|
|
"day_open": ctx.day_open},
|
|
action_taken="skipped_entry"))
|
|
return _done(False, skip, throttle=is_auto, rank_factor=rank_factor)
|
|
|
|
return _done(True, rank_factor=rank_factor)
|
|
|
|
def _emit_gate_receipt(self, ctx: EntryContext, decision: GateDecision) -> None:
|
|
"""One compact decision receipt per gate evaluation, behind
|
|
TRADING_GATE_RECEIPTS (default on). The substrate plan 006 replays; the
|
|
``gates`` map schema is a soft contract (additive changes only)."""
|
|
if not self.settings.gate_receipts:
|
|
return
|
|
verdict = "passed" if decision.passed else f"rejected: {decision.reason}"
|
|
self.store.emit(EntryGateEvaluatedEvent(
|
|
timestamp=datetime.now(), mode=self.mode,
|
|
correlation_id=self.session_id,
|
|
message=f"{ctx.symbol} entry gates ({ctx.source}) — {verdict}",
|
|
symbol=ctx.symbol, source=ctx.source, passed=decision.passed,
|
|
reject_reason=decision.reason, gates=decision.gates))
|
|
|
|
def set_operator_paused(self, paused: bool) -> None:
|
|
"""Operator PAUSE/RESUME (command bus). Blocks new entries on both paths."""
|
|
self._operator_paused = bool(paused)
|
|
|
|
def request_approvals_for_ready_signals(self, rank_map: dict | None = None) -> list[str]:
|
|
"""Turn fresh ready signals into approval requests. Returns order ids."""
|
|
if not self.settings.enabled:
|
|
return []
|
|
|
|
# operator pause — slow-path half of the BOTH-paths check (submit_breakout_now
|
|
# holds the other half; missing either would let pause leak on that path).
|
|
if self._operator_paused:
|
|
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"]
|
|
# Dedup FIRST (as before extraction): the slow path re-scans every ready
|
|
# signal each tick, so already-handled names must be skipped silently
|
|
# here — this is what keeps _requested_symbols from re-emitting a policy
|
|
# event every tick. The shared gate's own dedup then only bites the fast
|
|
# trigger path (per-fire, no re-scan).
|
|
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
|
|
entry = signal.get("entry_price")
|
|
stop = signal.get("stop_loss_price")
|
|
if not entry or not stop or stop >= entry:
|
|
continue # price-sanity (silent; path-specific float coercion)
|
|
entry_f0 = float(entry)
|
|
# Marketable fill model prices from the LIVE last trade (the slow path
|
|
# runs minutes behind the break — a limit at the level rests behind the
|
|
# market and never fills on a runner; observed 6-for-6 unfilled 07-20).
|
|
# live_ref also feeds entry_ref so the anti-chase extension gate bites
|
|
# on the slow path instead of being an entry_ref==trigger no-op.
|
|
live_ref = entry_f0
|
|
if (self.settings.entry_fill_model == "marketable"
|
|
and self.price_provider is not None):
|
|
try:
|
|
last = self.price_provider(symbol)
|
|
except Exception: # noqa: BLE001 — feed hiccup: fall back to level
|
|
last = None
|
|
if last:
|
|
live_ref = max(entry_f0, float(last))
|
|
# ONE shared gate sequence (plan 005). The slow path rests a limit AT the
|
|
# level, so entry_ref == trigger == the entry level. rank arrives from the
|
|
# trigger book via rank_map (None -> factor 1.0, no-op).
|
|
decision = self._evaluate_entry_gates(EntryContext(
|
|
symbol=symbol, source="auto", entry=entry_f0, stop=float(stop),
|
|
trigger=entry_f0, entry_ref=live_ref,
|
|
gap_pct=signal.get("gap_pct"), price=entry_f0,
|
|
cum_volume=signal.get("cum_volume"),
|
|
rvol=signal.get("relative_volume"),
|
|
above_vwap=signal.get("above_vwap"), vwap=signal.get("vwap"),
|
|
quality_score=signal.get("quality_score"),
|
|
rank=(rank_map or {}).get(symbol),
|
|
day_open=signal.get("day_open"), halted=False,
|
|
confirm_ts=signal.get("timestamp"),
|
|
held=held, pending=pending))
|
|
if not decision.passed:
|
|
if decision.throttle:
|
|
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)
|
|
# scale risk by rank (rank-1 full, rank-2 half) exactly as the fast path;
|
|
# at concentrate_top_n=0 the factor is 1.0, so this is a no-op today.
|
|
sizing = calculate_position_size(
|
|
float(entry),
|
|
float(stop),
|
|
equity=eq,
|
|
config=PositionSizingConfig(
|
|
risk_per_trade_pct=self.settings.risk_per_trade_pct * decision.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 * decision.rank_factor,
|
|
)
|
|
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(live_ref * (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"}
|
|
# operator pause — fast-path half of the BOTH-paths check (see
|
|
# request_approvals_for_ready_signals for the slow-path half).
|
|
if self._operator_paused:
|
|
return {"ok": False, "skipped": "paused"}
|
|
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"}
|
|
# Price-sanity + entry_ref derivation stays here (path-specific coercion from
|
|
# a live 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. The extension gate downstream needs a real entry_ref, so resolve it
|
|
# before the shared gate.
|
|
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"}
|
|
|
|
# ONE shared gate sequence (plan 005): confirmation-speed, session,
|
|
# population, VWAP, quality, concentrate-by-rank, daily cap, dedup/cooldown/
|
|
# reentry, dilution veto, anti-chase/halt — the identical logic the slow
|
|
# auto path runs. The fast public signature carries no VWAP/quality inputs,
|
|
# so those gates stay fail-open here (None -> never block).
|
|
decision = self._evaluate_entry_gates(EntryContext(
|
|
symbol=symbol, source="fast", entry=entry_ref, stop=stop_f,
|
|
trigger=trigger_f, entry_ref=entry_ref,
|
|
gap_pct=gap_pct, price=(price if price else last_price),
|
|
cum_volume=(cum_volume or None), rvol=rvol,
|
|
above_vwap=None, vwap=None, quality_score=None,
|
|
rank=rank, day_open=day_open, halted=halted,
|
|
confirm_ts=self._now()))
|
|
if not decision.passed:
|
|
return {"ok": False, "skipped": decision.reason}
|
|
rank_factor = decision.rank_factor
|
|
|
|
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) # fresh-broker belt now lives inside
|
|
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
|
|
|
|
@_locked
|
|
def flatten_symbol(self, symbol: str, reason: str = "operator_flatten_symbol") -> dict:
|
|
"""Operator single-name flatten (command bus): cancel the symbol's resting
|
|
protective legs, then market-close it (the naked-leg-safe helper shared with
|
|
the exit manager). Does NOT block re-entry / halt the session — that's the
|
|
whole-book ``close_session``'s job. Best-effort: never raises."""
|
|
client = getattr(self.executor, "client", None)
|
|
if client is None or not hasattr(client, "close_position"):
|
|
return {"ok": False, "symbol": symbol, "error": "no broker client to flatten"}
|
|
try:
|
|
self._release_and_close(client, symbol)
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"ok": False, "symbol": symbol, "error": str(exc)}
|
|
self.store.emit(
|
|
RiskRuleTriggeredEvent(
|
|
timestamp=datetime.now(), mode=self.mode, correlation_id=self.session_id,
|
|
message=f"Operator flatten {symbol} ({reason})",
|
|
rule_type="operator_flatten_symbol", rule_value=0.0,
|
|
current_state={"symbol": symbol}, action_taken="flatten_symbol",
|
|
)
|
|
)
|
|
return {"ok": True, "symbol": symbol}
|
|
|
|
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
|
|
|
|
@_locked
|
|
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, rank_map: dict | None = None) -> dict:
|
|
"""One execution pass: expire stale entries, request approvals, auto-execute.
|
|
|
|
``rank_map`` ({symbol: rank}) is threaded from the trigger book so the slow
|
|
auto path can honour concentrate-by-rank identically to the fast path."""
|
|
backed_out = self.expire_stale_entries()
|
|
requested = self.request_approvals_for_ready_signals(rank_map=rank_map)
|
|
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
|