Skip to content

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:

  1. Production residual_distortion_bps window if warmed (unchanged when available).
  2. Preview preview_baseline window if for_exit=True AND production still warming AND preview is seeded.
  3. Legacy capped last_anchor_divergence_bps fallback.

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" AND cfg.recovery_structural_early_exit_enabled AND self._strategy.last_structural_basis_bps is not None → Option B path. |structural| < cfg.recovery_exit_bias_threshold_bps advances _anchor_idle_stability_ticks; reaching cfg.recovery_stability_ticks exits. Hostile resets counter. Early return prevents 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:

anchor_divergence_bps = ((mid_price - amm_price) / amm_price) * 10000.0

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) — uses abs(mean) and abs(x) > threshold. Unaffected.
  • _anchor_divergence_obs summary stats — stored signed statistics flip; now match canonical. Correct.
  • anchor_error_bps DB 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 — uses abs(). Behavior unchanged (verified in test_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_engine fixture gained eng._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 reads self._strategy.last_structural_basis_bps. Setting to None gates Option B off, preserving every original window-based assertion in this file.
  • tests/test_flag_042_degraded_recovery.py — same stub applied to _anchor_engine for the same reason.
  • tests/test_phase_7_2_clob_switch.py — four signed-value assertions on _strategy.last_anchor_divergence_bps updated from the old convention to canonical. Magnitudes ≈ old (sub-bps drift from divisor change), signs flipped; CLOB-switch decision uses abs() 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

  1. Changelog note for anchor_error_bps DB 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.
  2. Dashboard re-verification_render_anchor reads anchor_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).
  3. summarize_paper_run session-line — should show the same fields as before, now populated from new keys. Worth a spot-check on the first post-merge session.
  4. 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