From b6a4040e4574920807e20027dae290fd2cbb23bf Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 13 Jul 2026 23:20:10 -0400 Subject: [PATCH] entry-gate: decision receipts per evaluation (plan 005 step 3) New EntryGateEvaluatedEvent (symbol, source, passed, reject_reason, gates map) emitted once per _evaluate_entry_gates decision on both paths, behind TRADING_GATE_RECEIPTS (default on; dataclass default off so unit tests opt in). Added to the 14-day nightly purge tuple (high-volume diagnostic) and .env.example. Turns 'why didn't it trade X?' into a query and is the substrate plan 006 replays. Co-Authored-By: Claude Fable 5 --- .env.example | 1 + research/premarket_eval.py | 3 +- storage/event_schema.py | 19 ++++++++++++ tests/unit/test_entry_gate_parity.py | 44 +++++++++++++++++++++++++++- trading_execution.py | 21 ++++++++++++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 75c103e..fbe02d4 100644 --- a/.env.example +++ b/.env.example @@ -133,6 +133,7 @@ TRADING_MAX_FRESH_ENTRIES_PER_DAY=10 # EXPERIMENT 2026-07-02 (owner-authorized) TRADING_MAX_CONCURRENT_POSITIONS=10 # raised 6->10; the 60% gross-notional cap below still hard-bounds TOTAL exposure (so this = more, smaller positions, not more risk) TRADING_REQUIRE_ABOVE_VWAP=1 # VWAP entry gate: only take breakouts trading >= session VWAP. The ONE validated selection signal (above-VWAP reaches +1R ~1.5x as often, n=3,110 from the labeler). 0=shadow-only (logs below-VWAP signals as rule_type=vwap_below but still enters). Set 0 to revert to pre-gate behavior. TRADING_UNIFIED_ENTRY=1 # run the fast path's anti-chase gates (day-extension: don't enter a name whose breakout level is >30% above the session open; halt) on the LIVE auto path too. Closes the gap where those guards only ran on the rarely-used fast trigger path. Fail-open on missing data. 0=pre-unification (auto path skips these gates). +TRADING_GATE_RECEIPTS=1 # emit one entry_gate_evaluated event per gate evaluation (both paths) with the full pass/reject gate map — turns 'why didn't it trade X?' into a query. High-volume diagnostic, purged after 14 days like other observability spam. 0=off (no receipts). TRADING_ENTRY_FILL_MODEL=resting # auto-path fill: 'resting' = rest a limit AT the breakout level (fills only on a pullback, misses runners that gap up). 'marketable' = limit a hair above the trigger so it fills as price breaks UP through it (catches runners, the fast path's behavior; the limit still caps the fill so no gap-chase). VALIDATED LIVE 2026-06-24 open (NOK/SOFI/SSPC filled near-trigger, no chase) then reverted to resting (safe default). Flip to 'marketable' to use it. # Account-aware sizing: positions size off REAL broker equity (auto — so a $300 # live account sizes for $300, not the $100k default). One position's value is diff --git a/research/premarket_eval.py b/research/premarket_eval.py index e35bed6..6c92c80 100644 --- a/research/premarket_eval.py +++ b/research/premarket_eval.py @@ -95,7 +95,8 @@ def main() -> None: # are kept forever. Autovacuum (tuned to 2%) reclaims the space. try: spam = ("module_tick", "criteria_evaluated", "signal_blocked", - "account_summary_updated", "account_positions_updated", "account_orders_updated") + "account_summary_updated", "account_positions_updated", "account_orders_updated", + "entry_gate_evaluated") ph = ",".join(["?"] * len(spam)) cur = con.execute( f"DELETE FROM events WHERE event_type IN ({ph}) " diff --git a/storage/event_schema.py b/storage/event_schema.py index 77fbc62..99dad9b 100644 --- a/storage/event_schema.py +++ b/storage/event_schema.py @@ -42,6 +42,7 @@ class EventType(str, Enum): MODULE_TICK = "module_tick" DATA_VALIDATION = "data_validation" SYMBOL_EVALUATION_DETAIL = "symbol_evaluation_detail" + ENTRY_GATE_EVALUATED = "entry_gate_evaluated" class BaseEvent(BaseModel): @@ -274,6 +275,24 @@ class RiskRuleTriggeredEvent(BaseEvent): action_taken: str = Field(description="Action taken in response") +class EntryGateEvaluatedEvent(BaseEvent): + """Decision receipt for one entry-gate evaluation (plan 005). One event per + final pass/reject on either path — the raw material for 'why didn't it trade + X?' queries and plan 006 replay. High-volume diagnostic: purged after 14 days. + ``gates`` maps each evaluated gate -> {value, threshold, outcome}.""" + + event_type: EventType = EventType.ENTRY_GATE_EVALUATED + symbol: str = Field(description="Ticker symbol") + source: str = Field(description="Entry path: 'auto' (slow) or 'fast'") + passed: bool = Field(description="Whether all gates passed") + reject_reason: str | None = Field( + default=None, description="First failing gate, or None on a pass" + ) + gates: dict = Field( + default_factory=dict, description="Per-gate {value, threshold, outcome}" + ) + + class BrokerHealthChangedEvent(BaseEvent): """Event when broker health changes.""" diff --git a/tests/unit/test_entry_gate_parity.py b/tests/unit/test_entry_gate_parity.py index 975b716..4d1bd39 100644 --- a/tests/unit/test_entry_gate_parity.py +++ b/tests/unit/test_entry_gate_parity.py @@ -20,6 +20,7 @@ from datetime import datetime import pytest from storage.event_schema import EventMode, SignalReadyEvent +from trading_execution import EntryContext from storage.event_store import EventStore from trading_execution import ExecutionSettings, TradingExecutionService from trading_mode import TradingModeSettings @@ -167,7 +168,6 @@ def test_fast_vwap_gate_closure(store): # with source="fast" (the fast public signature carries no above_vwap, so the # closure is only reachable when a below-VWAP value is supplied). Before # extraction the fast path had no VWAP gate at all. - from trading_execution import EntryContext svc = _make(store, require_above_vwap=True) ctx = EntryContext(symbol="X", source="fast", entry=14.0, stop=13.45, trigger=14.0, entry_ref=14.0, gap_pct=0.25, above_vwap=False, @@ -190,6 +190,48 @@ def test_gate_parity_below_vwap_enforced_both_paths(store): assert d.reason == "vwap_below", src +# ---- decision receipts (step 3) ---- + +def _receipts(store): + return [json.loads(r["payload_json"]) + for r in store.query_events(event_type="entry_gate_evaluated")] + + +def _ctx(symbol="X", source="fast", **kw): + base = dict(symbol=symbol, source=source, entry=14.0, stop=13.45, + trigger=14.0, entry_ref=14.0, gap_pct=0.25, confirm_ts=T0) + base.update(kw) + return EntryContext(**base) + + +def test_receipt_on_reject_carries_reason_and_gate_map(store): + svc = _make(store, gate_receipts=True, min_entry_gap_pct=0.10, min_day_volume=300_000) + d = svc._evaluate_entry_gates(_ctx(gap_pct=0.02, cum_volume=1_000_000, price=14.0)) + assert d.passed is False + r = _receipts(store) + assert len(r) == 1 + assert r[0]["passed"] is False + assert r[0]["reject_reason"] == "population_below" + assert r[0]["gates"]["population"]["outcome"] == "reject" + + +def test_receipt_on_pass_records_gate_map(store): + svc = _make(store, gate_receipts=True) + d = svc._evaluate_entry_gates(_ctx(rank=1)) + assert d.passed is True + r = _receipts(store) + assert len(r) == 1 + assert r[0]["passed"] is True + assert r[0]["reject_reason"] is None + assert r[0]["gates"]["population"]["outcome"] == "pass" + + +def test_receipt_off_by_flag_emits_nothing(store): + svc = _make(store, gate_receipts=False, min_entry_gap_pct=0.10) + svc._evaluate_entry_gates(_ctx(gap_pct=0.02, cum_volume=1_000_000, price=14.0)) + assert _receipts(store) == [] + + # ---- FAST path (submit_breakout_now): decision = skipped string ---- def test_fast_in_population_fires(store): diff --git a/trading_execution.py b/trading_execution.py index a01da15..7918b1e 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -29,6 +29,7 @@ from alpaca_paper.execution import ( ExecutionRequest, ) from storage.event_schema import ( + EntryGateEvaluatedEvent, EventMode, OrderApprovalRequestedEvent, OrderApprovedEvent, @@ -241,6 +242,12 @@ class ExecutionSettings: # 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": @@ -328,6 +335,7 @@ class ExecutionSettings: catalyst_veto_min_conviction=float( values.get("NEWS_DILUTION_VETO_CONVICTION", "0.6") ), + gate_receipts=flag("TRADING_GATE_RECEIPTS", "1"), ) @@ -1061,7 +1069,18 @@ class TradingExecutionService: return _done(True, rank_factor=rank_factor) def _emit_gate_receipt(self, ctx: EntryContext, decision: GateDecision) -> None: - """Decision receipt hook — populated in plan 005 step 3.""" + """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 request_approvals_for_ready_signals(self, rank_map: dict | None = None) -> list[str]: """Turn fresh ready signals into approval requests. Returns order ids."""