Files
momentum-system/plans/005-entry-path-unification.md
momentum devandClaude Fable 5 653f13a7d9 milestone 2026-07: plans 001-007 + ideation artifacts + architecture diagrams
Recon (8 clusters) -> ideation (48->14->7, all verified) -> plans, cold-read
verified by 8 fresh-context reviewers, 33 plan defects fixed pre-execution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:28:07 -04:00

13 KiB
Raw Permalink Blame History

Plan 005: One entry gate for both paths, with a decision receipt for every pass/fail

Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in plans/README.md.

Drift check (run first): git diff --stat d54a2a9..HEAD -- trading_execution.py run_live_paper.py storage/event_schema.py On any in-scope drift, compare "Current state" excerpts before proceeding; mismatch = STOP. Diffs introduced by plans 001–004 are EXPECTED — see plans/README.md "Expected drift".

Status

  • Priority: P2
  • Effort: M
  • Risk: MED-HIGH (live entry logic; mitigated by characterization tests + shadow parity mode)
  • Depends on: 001 (CI gate), 003 (money-path hardening), 004 (population module — the first shared gate already extracted)
  • Category: tech-debt / correctness
  • Planned at: commit d54a2a9, 2026-07-13

Why this matters

Live entries flow through the SLOW auto-approval path (request_approvals_for_ready_signals); the FAST trigger path (submit_breakout_now) is architecturally alive but rarely used ("dead" per the boot log). The two paths have drifted: the VWAP gate and quality shadow exist only on the slow path; rank-concentration exists only on the fast path — and the boot log itself warns about it (run_live_paper.py:1026-1028: "rank-concentration is enforced ONLY on the dead fast-trigger path, NOT the live auto-approval path"). This drift class already produced the daily-cap 44-vs-5 incident (fixed in ac1ac50). Unifying onto one gate function means every future gate (including promoted Experiment-OS winners, plan 006) lands on both paths by construction — and emitting a compact decision receipt per evaluation turns "why didn't it trade X?" from a forensic session into a query. Receipts are also the raw material plan 006 replays.

Current state

  • SLOW path: trading_execution.py request_approvals_for_ready_signals — inline gate sequence per signal (~lines 860–1010): dedup/_requested_symbols → _outside_session() (~907) → _population_reject(...) (~910) → VWAP gate (~935: if signal.get("above_vwap") is False: — enforced when settings.require_above_vwap, else shadow-logged) → quality shadow → anti-chase/day-extension (_anti_chase_skip) → sizing → approval request.
  • FAST path: submit_breakout_now (~1205–1290): enabled → _daily_loss_breach() → max-concurrent → _late_confirmation → _outside_session() (~1219) → _population_reject (~1222) → rank_risk_factor(rank, settings.concentrate_top_n) (~1228, fast-only) → daily fresh-entry cap → held/pending/_requested_symbols dedup → cooldown → _exited_today → dilution veto → price sanity → _anti_chase_skip (~1274) → sizing. No VWAP gate, no quality gate.
  • Already-shared helpers: _outside_session, _population_reject, _anti_chase_skip, _late_confirmation, benches/dedup sets. The drift is only VWAP/quality (slow-only) vs rank (fast-only) plus ordering/structure. NOTE: on the slow path, anti-chase (~989) and the liquidity cap (~1023) are conditional on settings.unified_entry (TRADING_UNIFIED_ENTRY, default "1" — a documented rollback switch); the fast path runs anti-chase unconditionally. Step 2 preserves this flag semantics.
  • Boot warning (run_live_paper.py:1024-1029) — delete it when rank runs on both paths.
  • Events: typed pydantic events in storage/event_schema.py; risk skips today emit RiskRuleTriggeredEvent (see the population-reject emit at trading_execution.py:915-928). Convention: events carry timestamp, mode, correlation_id, message + structured fields.
  • Retention: nightly job purges high-volume diagnostic event types older than 14 days (research/premarket_eval.py purge list) — receipts must be added to that list.

Commands you will need

Purpose Command Expected
Suite DATABASE_URL=<pg url> python -m pytest -q all pass
Targeted DATABASE_URL=<pg url> python -m pytest tests/unit/test_execution*.py tests/unit/test_trading_execution.py tests/unit/test_entry_gate*.py -q all pass (test_trading_execution.py is the main file for the refactored code — the test_execution* glob misses it)
Receipt query smoke ./momentum inspect events (after a dev-loop run) entry_gate_evaluated events visible

Scope

In scope:

  • trading_execution.py (the two path functions + a new _evaluate_entry_gates method)
  • storage/event_schema.py (one new event type: EntryGateEvaluatedEvent)
  • research/premarket_eval.py (add the receipt event-type STRING to the 14-day purge list)
  • run_live_paper.py (rank-map handoff at the call site + remove the boot warning at 1024-1029 once true)
  • tests/unit/test_entry_gate_parity.py (create) + adjustments to existing execution tests
  • .env.example (TRADING_GATE_RECEIPTS, default on)

Out of scope:

  • Changing any gate's THRESHOLD or adding/removing gates (VWAP stays enforced-by-flag exactly as today; rank stays concentrate_top_n-gated; quality stays shadow) — this plan changes topology, not policy.
  • The trigger book / arming logic in run_live_paper.py (rank is computed at arm time; it arrives as an argument).
  • Dashboard rendering of receipts (plan 007 may surface them; not here).
  • strategy/population.py internals (plan 004).

Git workflow

  • Branch: advisor/005-entry-path-unification
  • Commits: characterization tests → shared gate function (parity mode) → path switches → receipts → boot-warning removal.
  • Do NOT push or open a PR.

Steps

Step 1: Characterization tests for BOTH paths as they are

tests/unit/test_entry_gate_parity.py: for a matrix of signals (in-population, below-VWAP, dead-band gap, ranked outside top-N, benched symbol, cooldown, session-closed, cap-reached), record the CURRENT decision of each path (slow: approval requested or skip reason via emitted events; fast: the skipped string). Pin them as expectations. This encodes today's asymmetries EXPLICITLY (e.g. below-VWAP: slow skips when enforced, fast proceeds — that is today's truth).

Verify: targeted pytest green against unmodified code.

Step 2: Extract _evaluate_entry_gates

One private method both paths call:

def _evaluate_entry_gates(self, ctx: EntryContext) -> GateDecision:
    """Run the full ordered gate sequence; return first-reject or pass.
    ctx carries: symbol, gap_pct, price/entry_ref, cum_volume, rvol,
    above_vwap (None ok), rank (None on slow path today), trigger, stop,
    day_open, halted, source ("auto"|"fast")."""

Order: session → late-confirmation (_late_confirmation, already on both paths at ~886/~1216) → population → VWAP (respect require_above_vwap; shadow-log below-VWAP exactly as the slow path does today) → quality (enforcement-flagged via min_quality_score; with the default 0.0 the fast-path decision is unchanged, so moving it into the shared gate is NOT a behavior closure) → rank (rank_risk_factor; when ctx.rank is None, pass rank_factor=1.0 — see STOP #3) → cap (source=="fast" only — the slow path's cap is enforced later in approve_order (trading_execution.py:1102-1108) and stays there untouched, as do both counter-increment sites; moving it to request time would be a third behavior change this plan does not make) → dedup/bench/cooldown/reentry → dilution veto → price sanity → anti-chase. Return GateDecision(passed, reason, rank_factor, shadow_notes). Move code, don't rewrite it; each gate body is lifted verbatim from whichever path owns it today.

unified_entry flag fate: the slow path's anti-chase and liquidity-cap are today conditional on settings.unified_entry (TRADING_UNIFIED_ENTRY, default "1"; documented rollback switch in .env.example). Keep that conditional INSIDE the shared gate for source=="auto" (fast stays unconditional, as today) — the rollback switch survives, behavior at flag=0 is unchanged.

Wire BOTH paths to call it. Rank on the slow path: do NOT touch the watcher's signal payload or projections (the ready-signal payload is built in runtime/watcher.py from the detector's setup dict, which has no rank, and query_ready_signals_snapshot in storage/projections.py has a fixed field list — both out of scope). Instead pass a {symbol: rank} map from the trigger book into request_approvals_for_ready_signals at its call site in run_live_paper.py (the book computes rank at arm time; t.rank is visible in the trigger-thread code ~line 959 — expose the book's current ranks via a small accessor and hand the dict in as a new optional parameter).

Verify: step-1 characterization tests — the ONLY acceptable diffs are the two documented asymmetries closing (fast now VWAP-gated; slow now rank-gated when rank present). Update exactly those expectations, cite this step in the test comment. Everything else stays pinned.

Step 3: Decision receipts

New EntryGateEvaluatedEvent in storage/event_schema.py (match existing event conventions): symbol, source, passed, reject_reason, gates: dict (each gate → {value, threshold, outcome} — compact, one event per final decision, NOT one per gate). Emit from _evaluate_entry_gates behind TRADING_GATE_RECEIPTS=1 (default on). Add the event-type string "entry_gate_evaluated" to the nightly 14-day purge tuple in research/premarket_eval.py:96-97 (the spam = ("module_tick", "criteria_evaluated", …) tuple holds snake_case strings, not class names; diagnostic volume — order/fill events remain permanent).

Verify: unit test asserts one receipt with full gate map on a reject and on a pass; suite green.

Step 4: Delete the boot warning

Remove run_live_paper.py:1024-1029 (the rank-concentration warning) — now false. Update the boot gate summary line (1018-1022) to include rank top-N state.

Verify: grep -n "enforced ONLY on the dead fast-trigger path" run_live_paper.py → no matches.

Test plan

  • tests/unit/test_entry_gate_parity.py — the characterization matrix (~10 cases × 2 paths), then updated for the two closed asymmetries.
  • Receipt tests (step 3): reject emits receipt with reason; pass emits receipt; TRADING_GATE_RECEIPTS=0 emits nothing.
  • Regression: existing tests/unit/test_execution*.py AND tests/unit/test_trading_execution.py (references unified_entry) untouched semantics must stay green — any other failure there means the extraction changed behavior beyond the two documented closures → STOP.
  • Verification: DATABASE_URL=… python -m pytest -q green.

Done criteria

  • grep -c "_population_reject" trading_execution.py → 2 (the def + one call inside _evaluate_entry_gates; count is 3 at plan time)
  • Both request_approvals_for_ready_signals and submit_breakout_now call _evaluate_entry_gates
  • grep -n "EntryGateEvaluatedEvent" storage/event_schema.py trading_execution.py → defined + emitted; grep -n "entry_gate_evaluated" research/premarket_eval.py storage/event_schema.py → purge-listed + event-type registered
  • Boot warning gone; boot summary shows rank state
  • Full suite green; parity test documents exactly two behavior closures
  • plans/README.md updated

STOP conditions

  • The slow path's gate sequence at HEAD contains gates not listed in "Current state" (drift/growth) — extend characterization first, report.
  • Threading rank into the ready-signal payload requires touching more than run_live_paper.py's arm/watch steps + the signal dict — report the actual data path instead of improvising.
  • Rank-on-slow-path decision: if enabling rank on the live path would change today's LIVE behavior because TRADING_CONCENTRATE_TOP_N > 0 is already set in production env — STOP and report: the owner must explicitly confirm turning rank concentration live (it was previously enforced nowhere that matters; making it real is a trading-behavior change, not a refactor).
  • Any characterization diff beyond the two documented closures.

Maintenance notes

  • From now on a new entry gate is added in exactly one place. A PR adding a gate anywhere else in either path function should be blocked in review.
  • Receipts are the substrate plan 006 replays; their gates map schema is now a soft contract — additive changes only.
  • The ctx.rank is None ⇒ factor 1.0 fail-open means signals with no rank are NOT concentration-limited; when plan 006's one-bullet experiment reports, the owner may want rank mandatory — that is a policy change to make deliberately, not here.
  • Deferred: quality-grade gate promotion (currently shadow) — the runner-grade memory says the GRADE survives; promoting it to an enforced gate is an experiment for plan 006, not a refactor.