from: Orion
to: Vesper
cc: Katja, Atlas
re: Patch delivery — fix/flag-053-anchor-idle-exit-lockout
date: 2026-04-22
status: READY FOR REVIEW
branch: fix/flag-053-anchor-idle-exit-lockout
ref: FLAG-053 (ANCHOR_IDLE exit-evaluator lockout), Atlas ruling #2 (option iii), Vesper prework 2026-04-22
base: 866f6fe (test(anchor_idle_guard_semantics): 14 tests for FLAG-050 reset + episode routing)
Orion Delivery — fix/flag-053-anchor-idle-exit-lockout¶
Four commits on fix/flag-053-anchor-idle-exit-lockout, cut off 866f6fe. Delivers option (iii) in full: Option A (earlier residual eligibility via preview window) + Option B (benign-regime structural early-exit) + full sign-convention standardization on last_anchor_divergence_bps + engine_state anchor.* → anchor_divergence.* rename. Phase separation (A and B never concurrent) enforced by source-label branching in the exit evaluator, satisfying the structural requirement from Part 4 of your prework.
Commits¶
| # | Hash | Subject |
|---|---|---|
| C1 | b342763 |
feat(config): FLAG-053 preview residual + structural early-exit config |
| C2 | e590d4b |
feat(dual_signal): FLAG-053 preview_baseline accessor |
| C3 | d152979 |
feat(main_loop+strategy): FLAG-053 exit-evaluator preview window + structural early exit + sign flip |
| C4 | 0cca27c |
test(flag_053): 13 validation tests + adjacent regression adjustments |
Diffstat (4 commits, 866f6fe..HEAD): 14 files changed, +1111 / −64.
Patches¶
02 Projects/NEO Trading Engine/08 Patches/flag-053-anchor-idle-exit-lockout/ — four numbered .patch files, 0001 → 0004.
What landed, per commit¶
C1 — feat(config): FLAG-053 preview residual + structural early-exit config¶
New fields on AnchorDualSignalConfig (neo_engine/config.py):
residual_exit_preview_warmup_ticks: int = 20— preview window warm-up (shorter than production's 50, to unlock earlier exit eligibility).residual_exit_preview_lookback_ticks: int = 10— preview hysteresis lookback.recovery_structural_early_exit_enabled: bool = True— master switch for Option B (default on).recovery_exit_bias_threshold_bps: float = 4.0—|structural| < this→ benign regime, stability counter accumulates.
YAML mirrors added to config/config.yaml, config/config.example.yaml, config/config_live_stage1.yaml under strategy.anchor_dual_signal:. Defaults active on live_stage1.
C2 — feat(dual_signal): FLAG-053 preview_baseline accessor¶
AnchorDualSignalCalculator.preview_baseline property (neo_engine/dual_signal_calculator.py). Returns the current EMA value once effective_sample_count >= residual_exit_preview_warmup_ticks — independent of production baseline's 50-tick seed. Returns None before the preview seed threshold. Pure accessor on the same EMA state; no separate calculator instance.
C3 — feat(main_loop+strategy): FLAG-053 exit-evaluator preview window + structural early exit + sign flip¶
Three coordinated changes in one commit (they share invariants and must land together to keep regression green):
Exit-evaluator Option A + B, phase-separated.
_select_anchor_guard_window(for_exit: bool = False) (main_loop.py:2729+) now returns a tri-layer source selector:
- Production
residual_distortion_bpswindow if warmed (unchanged when available). - Preview
preview_baselinewindow iffor_exit=TrueAND production still warming AND preview is seeded. - Legacy capped
last_anchor_divergence_bpsfallback.
Entry path calls with for_exit=False — preview is NEVER selected for entry, preserving the saturated-guard semantic.
_evaluate_anchor_idle_exit (main_loop.py:2887+) now:
- Reads the selected source label from
_select_anchor_guard_window(for_exit=True). - If
source == "last_anchor_divergence_bps"ANDcfg.recovery_structural_early_exit_enabledANDself._strategy.last_structural_basis_bps is not None→ Option B path.|structural| < cfg.recovery_exit_bias_threshold_bpsadvances_anchor_idle_stability_ticks; reachingcfg.recovery_stability_ticksexits. Hostile resets counter. Earlyreturnprevents Option A from running in the same tick. - Otherwise (production residual window, or preview window, or Option B disabled / structural unavailable) → Option A / residual-window path, unchanged window-based bias + prevalence predicates.
This is the phase-separation guarantee you requested in prework Part 4: A and B never evaluate the same tick. Selection is by source-label — not by runtime flag — so every tick passes through exactly one branch. Test TPS_phase_separation_preview_does_not_use_structural pins this invariant.
Sign-convention standardization (Option "full", your prework Part 2).
StrategyEngine.calculate_quote (strategy_engine.py:248-253) recomputes last_anchor_divergence_bps from the canonical formula:
Now matches clob_vs_amm_divergence_bps (tick log) and structural_basis_bps (dual signal) — negative when AMM trades above CLOB. The previous form ((quote_anchor_price - mid_price) / mid_price) was (a) computed from the capped anchor, clamping magnitude at ±10, and (b) opposite sign to every other divergence metric. Both problems resolved. last_cap_applied still tracks whether the QUOTE anchor price hit the cap bounds — independent of how divergence is expressed.
Downstream consumer audit (findings pinned as block comment at strategy_engine.py:237-244):
_anchor_error_window(main_loop.py) — usesabs(mean)andabs(x) > threshold. Unaffected._anchor_divergence_obssummary stats — stored signed statistics flip; now match canonical. Correct.anchor_error_bpsDB column — stored signed value flips; uncapped magnitude is a free win. Schema unchanged, changelog note required on first post-merge session.- CLOB switch threshold at
strategy_engine.py:289— usesabs(). Behavior unchanged (verified intest_phase_7_2_clob_switch.py).
engine_state rename: anchor.* → anchor_divergence.*.
_log_anchor_divergence_summary (main_loop.py:5933+) now persists to 10 renamed keys (valid_count, min_bps, max_bps, mean_bps, median_bps, pct_cap_applied, pct_above_10bps, pct_above_12bps, pct_above_14bps, pct_error_above_5bps) under the anchor_divergence.* namespace. dashboard.py (_render_anchor) and summarize_paper_run.py (session summary reads) both migrated to read the new keys. tests/test_anchor_error_stat.py updated (and docstring notes the rename).
C4 — test(flag_053): 13 validation tests + adjacent regression adjustments¶
New test file tests/test_flag_053_anchor_idle_exit_lockout.py — 13 tests covering prework Part 4 validation criteria plus unit-level coverage:
| ID | Class | What it pins |
|---|---|---|
| T1 | TestStructuralEarlyExit |
Benign regime + Option B on → stability ticks accumulate → exits at recovery_stability_ticks |
| T2 | TestPreviewWindowRouting |
Production residual handover takes over once warm (no regression) |
| T3 | TestStructuralEarlyExit |
Hostile regime on Option B path resets the stability counter (no false exit) |
| T4 | TestSignConvention |
last_anchor_divergence_bps == (mid − amm)/amm × 10000 round-trip |
| T5 | TestPreviewWindowRouting |
Preview window advances on 20-tick schedule (shorter than production 50) |
| T6 | TestPreviewWindowRouting |
Preview selected for exit when production still warming AND preview seeded |
| T7 | TestPreviewWindowRouting |
Preview NOT selected for entry (saturated guard path still reads legacy capped window) |
| T8 | TestSignConvention |
Guard entry predicates unaffected by sign flip (abs-based) |
| TPS | TestStructuralEarlyExit |
Phase separation — preview-selected exits never traverse Option B branch |
| TES | TestSignConvention |
engine_state summary keys renamed to anchor_divergence.* |
| TU1 | TestPreviewBaseline |
preview_baseline returns EMA after >= residual_exit_preview_warmup_ticks |
| TU2 | TestPreviewBaseline |
preview_baseline returns None before seed |
| TU3 | TestSignFlipUnit |
Zero-divergence (mid == amm) → 0.0 under both conventions |
Validation coverage: T1 ↔ prework Test 1, T2 ↔ Test 2, T3 ↔ Test 3, T4 ↔ Test 4. Plus TPS for the structural invariant and T6 for the preview-feeder selector logic that the prework didn't enumerate but exit-reachability depends on.
Adjacent regression adjustments (same commit):
tests/test_anchor_idle_state.py—_exit_enginefixture gainedeng._strategy = MagicMock(); eng._strategy.last_structural_basis_bps = None.MagicMock(spec=NEOEngine)does not surface instance-level attributes set in__init__, and the new exit evaluator readsself._strategy.last_structural_basis_bps. Setting toNonegates Option B off, preserving every original window-based assertion in this file.tests/test_flag_042_degraded_recovery.py— same stub applied to_anchor_enginefor the same reason.tests/test_phase_7_2_clob_switch.py— four signed-value assertions on_strategy.last_anchor_divergence_bpsupdated from the old convention to canonical. Magnitudes ≈ old (sub-bps drift from divisor change), signs flipped; CLOB-switch decision usesabs()so pathing is unchanged. Module docstring expanded with FLAG-053 migration note.
Tests — adjacent regression¶
| Suite | Result |
|---|---|
test_flag_053_anchor_idle_exit_lockout.py |
13 / 13 PASS |
test_anchor_idle_state.py |
7 / 7 PASS |
test_flag_042_degraded_recovery.py |
15 / 15 PASS |
test_anchor_error_stat.py |
3 / 3 PASS |
test_phase_7_2_clob_switch.py |
5 / 5 PASS |
| Total adjacent | 43 / 43 PASS |
Full-suite baseline: 357 pre-existing failures on main (ModuleNotFoundError: plotly in sandbox — dep issue, unrelated). Same failures on this branch, no new ones introduced.
Answers to prework "Blocking questions for Orion"¶
Q1 — Option A threshold reduction, new config parameter?
→ New parameter pair: residual_exit_preview_warmup_ticks=20 and residual_exit_preview_lookback_ticks=10. Kept production warm-up at 50 / lookback at 20 so confidence for the live control signal is unchanged. Preview accessor feeds the exit-only selector (for_exit=True); entry path never sees it.
Q2 — Option B source / threshold?
→ self._strategy.last_structural_basis_bps directly (not a short rolling mean). Rationale: it's the raw uncapped canonical divergence, available immediately once a valid snapshot lands, and the 30-tick stability counter already provides the hysteresis buffer you called out. Threshold recovery_exit_bias_threshold_bps=4.0, matching the existing recovery_exit_bias_threshold_bps used by Option A.
Q3 — Full recompute or display flip?
→ Full recompute. Uncapping last_anchor_divergence_bps is the structural fix (capped values can never express "regime improved below cap" → the lockout); display-only flip would not have unblocked Option A either. Downstream consumer audit confirmed no correctness regression; only tests/test_phase_7_2_clob_switch.py needed expected-value updates (done in C4).
Apply instructions (copy/paste on Windows VS Code terminal)¶
From C:\Users\Katja\Documents\NEO GitHub\neo-2026\:
git fetch origin
git checkout main
git pull --ff-only origin main
# Defensive — handle the "branch already exists" case silently.
git branch -D fix/flag-053-anchor-idle-exit-lockout 2>$null
git checkout -b fix/flag-053-anchor-idle-exit-lockout
# Apply patches in order (no *.patch glob in PowerShell — use Get-ChildItem).
Get-ChildItem "C:\Users\Katja\Documents\Claude Homebase Neo\02 Projects\NEO Trading Engine\08 Patches\flag-053-anchor-idle-exit-lockout\" -Filter "*.patch" |
Sort-Object Name |
ForEach-Object { git am $_.FullName }
# Verify.
git log --oneline main..HEAD
Expected branch tip: 0cca27c with 4 commits ahead of main.
Post-merge ops notes¶
- Changelog note for
anchor_error_bpsDB column — first session after merge will store uncapped signed values in this column. Magnitudes may now exceed ±10 bps. Previous session data remains valid but capped; post-merge data is uncapped. - Dashboard re-verification —
_render_anchorreadsanchor_divergence.*now. Confirm summary block renders in first session;anchor.*keys are no longer written, so any lingering reader against the old namespace will go blank (none found in-tree). summarize_paper_runsession-line — should show the same fields as before, now populated from new keys. Worth a spot-check on the first post-merge session.- Pre-live replay (Vesper) — still required per your S40 diagnostic ruling. This delivery is the fix; the replay step against uncapped structural is unchanged from prior gating.
Status¶
Branch clean, patches generated, memo delivered. Awaiting Vesper review.
— Orion