Skip to content

Orion Delivery — feat/directional-drift-guard

Vesper — branch complete and all green per your Q1–Q5 rulings on the pre-code investigation. Ready for review + merge. Stacked on feat/anchor-saturation-guard (your prior approval).

Branch

feat/directional-drift-guard (off feat/anchor-saturation-guard, 5 commits, +1241 / −3)

Commits

# Hash Subject
C1 c2526dc feat(config): DirectionalDriftGuardConfig dataclass + YAML defaults (Phase 7.3)
C2 d8fa705 feat(db,main_loop): drift guard state + get_fill_events_since helper (Conditions A/B/C)
C3 204bdde feat(main_loop): drift guard evaluation + DEGRADED trigger (Step 8.5b)
C4 75b46a1 feat(db,main_loop): drift guard circuit_breaker_events persistence + WARNING log
C5 2277176 test(guard): directional drift guard — 24 tests (Phase 7.3)

Tests

  • New: 24 tests in tests/test_directional_drift_guard.py, all green.
  • Part A (5): config validation — defaults OK, burst_fill_count < 2 rejected, non-positive windows rejected (both burst + net-notional), non-positive net-notional threshold rejected, non-positive ticks-counters rejected.
  • Part B (17): evaluator via MagicMock(spec=NEOEngine) with time.time() patched.
    • Short-circuits: enabled=False (no DB read), session_id=None (no DB read), no fills (DB read, no trigger).
    • Condition A: buy burst, sell burst (symmetric), mixed-below-threshold no-op.
    • Condition B: buy-side net notional (+60 vs 50 thr), sell-side (−60 vs 50 thr, negative reported), offsetting flow no-op.
    • Condition C: fires after N ticks with no opposing fills, not armed before min_fills met, opposing fill resets counter.
    • Cross-condition: A precedence over B when both satisfied.
    • One-shot + log: no double-persist on subsequent triggered tick, [DRIFT_GUARD] WARNING emitted on first trigger.
    • Best-effort: persist failure swallowed (DEGRADED still entered, ERROR log), DB read failure swallowed (no propagate, no trigger, ERROR log).
  • Part C (2): integration against a real StateManager.
    • Trigger on Condition A → one circuit_breaker_events row with breaker="directional_drift_guard", session_id populated, manual_reset_required=1, context_json round-trippable to condition_triggered="A", side="buy".
    • get_fill_events_since on an empty session returns []; guard treats that as no-op.
  • Regression: 111 tests green in 2.06s across drift + anchor + reconciler + wallet-truth + anomaly-log + config + anchor-error-stat suites.

Run command:

python -m pytest tests/test_directional_drift_guard.py tests/test_anchor_saturation_guard.py \
  tests/test_ledger_reconciler.py tests/test_flag_036_wallet_truth_reconciliation.py \
  tests/test_reconciler_anomaly_log.py tests/test_config.py tests/test_config_invariants.py \
  tests/test_anchor_error_stat.py -q

Spec compliance

OR-logic guard at Step 8.5b (after anchor saturation guard, before "no intents" log / Step 9 submit). Fills are harvested each tick via session-scoped, strictly-greater-than ISO watermark polling of get_fill_events_since — works uniformly across live (reconciler Step 5) and paper (_simulate_paper_fills after Step 9, one-tick lag).

Trigger logic:

if not enabled:                                      -> no-op
if session_id is None:                               -> no-op
new_events = get_fill_events_since(session_id, wm)   -> may raise; swallowed w/ ERROR log
for each event:
    append to burst deque (ts, side, rlusd)
    append to net-notional deque (ts, signed_rlusd)
    if side changed vs _drift_last_fill_side:
        _drift_ticks_since_opposing_fill = 0
    advance watermark, last_side, fills_seen++
purge burst deque older than burst_window_seconds
purge net-notional deque older than net_notional_window_seconds
_drift_ticks_since_opposing_fill += 1

# First hit wins — OR logic:
A. buy_count >= burst_fill_count or sell_count >= burst_fill_count in burst window
B. |sum(signed_rlusd)| >= net_notional_threshold_rlusd in net-notional window
C. fills_seen_this_session >= no_opposing_fill_min_fills
   AND ticks_since_opposing >= no_opposing_fill_ticks

On the first firing tick in a session: 1. _drift_guard_triggered_this_session = True set before side effects (Vesper Ruling 2026-04-19 — re-entry safe). 2. WARNING log [DRIFT_GUARD] DEGRADED triggered — condition=<A|B|C> <condition-specific fields>. 3. One circuit_breaker_events row via record_circuit_breaker_event(breaker="directional_drift_guard", manual_reset_required=True, context={condition_triggered: "A|B|C", ...}). Writer failure is swallowed (logged at ERROR) and does not block DEGRADED. 4. _enter_degraded_mode(f"directional_drift_guard_{cond}") — idempotent, cancels live orders on first entry (existing D2.2 infrastructure), reason string discriminates A/B/C. 5. intents cleared (returned []) so Step 9 submit is a no-op this tick.

On subsequent ticks in the same session the guard stays silent (one-shot flag), DEGRADED stays on, and the pre-trade gate at execution_engine.py:1030 blocks any new submits until restart.

Atlas-proposed defaults wired identically across config.yaml, config_live_stage1.yaml, and config.example.yaml: - enabled: true - burst_fill_count: 3 - burst_window_seconds: 30.0 - net_notional_threshold_rlusd: 50.0 (calibration-TBD; flagged in config comments) - net_notional_window_seconds: 120.0 - no_opposing_fill_ticks: 15 - no_opposing_fill_min_fills: 1

Coexistence with the anchor guard: both guards transition to DEGRADED via _enter_degraded_mode(), which is idempotent. Independent one-shot flags (_anchor_guard_triggered_this_session vs _drift_guard_triggered_this_session) let both fire in the same tick without blocking each other. If the anchor guard fires first and clears intents, the drift guard still runs, its deques still update from new fill events, and a simultaneous trigger emits one [DRIFT_GUARD] row + WARNING log in addition to [ANCHOR_SAT]. No ordering conflict.

Deviations from spec

D1 — New DB helper get_fill_events_since(session_id, watermark_iso) — pre-approved. Your ruling on my investigation memo green-lit this as part of the branch. Implementation joins fills ⋈ orders and returns (fill_id, created_at_iso, side, quantity_rlusd) tuples oldest-first. No schema change, uses existing idx_fills_session_id.

D2 — net_notional_threshold_rlusd = 50.0 default — calibration TBD. Per my investigation memo and your ruling, the value is wired from YAML (no hardcoding), flagged as calibration-TBD in config.example.yaml comments. Atlas to tune post-first-live-session.

D3 — Wall-clock timestamps (not fills.created_at) for deque timestamps — pre-approved. Uses time.time() at observation, matching the participation filter precedent at main_loop.py:2103. Paper-mode fills enter the 30s burst window ~4s late (one tick) by construction; still inside both rolling windows.

No other deviations.

Files touched

config/config.example.yaml            |  23 ++
config/config.yaml                    |  24 ++
config/config_live_stage1.yaml        |  14 +-
neo_engine/config.py                  | 117 ++  (DirectionalDriftGuardConfig + validator + loader)
neo_engine/main_loop.py               | 293 ++  (7 state fields + evaluator + Step 8.5b wire-in)
neo_engine/state_manager.py           |  55 +   (Tuple import + get_fill_events_since)
tests/test_directional_drift_guard.py | 718 ++  (new, 24 tests)

Operator impact

  • Healthy sessions: zero observable change. Deques populate passively; evaluator returns early on all three conditions until a genuine flow-imbalance regime develops.
  • First trigger (per session): one [DRIFT_GUARD] WARNING log line, one DB row, DEGRADED transition (directional_drift_guard_A | _B | _C), live orders cancelled. No further drift-guard triggers this session.
  • Recovery: restart required (matches anchor guard + D2.2 pattern). Fresh session starts with cleared flag and empty deques.

Apply instructions (Windows)

Patches live at 02 Projects/NEO Trading Engine/patches/feat-directional-drift-guard/ (5 files, 0001 → 0005). Branch is stacked on feat/anchor-saturation-guard — apply that branch first if not already merged. From Katja's VS Code terminal:

cd C:\Users\Katja\Documents\NEO GitHub\neo-2026
git checkout -b feat/directional-drift-guard feat/anchor-saturation-guard
git am "C:\Users\Katja\Documents\Claude Homebase Neo\02 Projects\NEO Trading Engine\patches\feat-directional-drift-guard\*.patch"
python -m pytest tests/test_directional_drift_guard.py -q

Expected: 24 passed. Then run the wider regression if you want the 111-green sweep I ran here (command in the Tests section above).

Status

C1–C5 complete. Branch is clean and ready. Awaiting your review.

— Orion