Skip to content

feat: Auto-exclude coin on consecutive adverse-selection windows#132

Merged
keitaj merged 1 commit into
mainfrom
feat/adverse-auto-exclude
Apr 28, 2026
Merged

feat: Auto-exclude coin on consecutive adverse-selection windows#132
keitaj merged 1 commit into
mainfrom
feat/adverse-auto-exclude

Conversation

@keitaj

@keitaj keitaj commented Apr 28, 2026

Copy link
Copy Markdown
Owner

Summary

Why

AdverseSelectionTracker already produces a per-coin [adverse] Summary log every 300s. Until now the markout signal was observed only by humans (or by dynamic_offset's short 5s window). When a coin shifts to a structurally adverse regime mid-session, the existing guards do not stop it; this PR closes that gap with a small, self-contained feedback loop.

Changes

  • strategies/mm_config.py: new AutoExcludeConfig dataclass with __post_init__ validation (consecutive ≥ 1, min_fills ≥ 1, cooldown > 0, window_label ∈ {5s,30s,60s}). Added to MMConfig and from_legacy_dict.
  • ws/adverse_selection_tracker.py: each _log_summary window also appends a snapshot (ts, fills, avg_5s/30s/60s) to a per-coin deque (cap MAX_HISTORY = 10). New get_recent_windows(coin, n) returns the last n entries.
  • strategies/market_making_strategy.py: new _check_auto_exclude(coin) invoked from run() just before the cooldown skip. Defensive getattr(self, 'cfg', None) so tests that bypass __init__ continue to work. The cooldown deadline check in run() is no longer gated on loss_streak_limit > 0, since auto-exclude alone can now drive cooldowns through the shared _coin_cooldown_until map.
  • bot.py: six new CLI flags (--auto-exclude, --auto-exclude-threshold-bps, --auto-exclude-consecutive, --auto-exclude-min-fills, --auto-exclude-cooldown, --auto-exclude-window-label) and corresponding entries in _STRATEGY_PARAMS['market_making'].
  • README.md: human description in the market_making section + AI YAML reference entries.

Backward compatibility

  • Default-disabled. Existing deployments are unaffected unless they pass --auto-exclude together with --enable-adverse-selection-log.
  • No changes to risk_manager.py Level 1 guardrails.
  • Existing flat instance attributes preserved as before; no new internal API breakage.

Test plan

  • Unit tests added:
    • tests/test_mm_config.py: dataclass defaults + four validation cases + extended from_legacy_dict coverage.
    • tests/test_adverse_selection_tracker.py: append-on-summary, get_recent_windows last-n / unknown coin / n<=0 / MAX_HISTORY cap / avg=None semantics.
    • tests/test_adverse_auto_exclude.py (new): trigger / borderline / threshold-exact / short history / min_fills / None-avg / 30s window label / cooldown lifecycle / shared cooldown coexistence / constructor validation propagation.
  • flake8 passes.
  • pytest passes (879 tests total, +28 new).
  • No regressions in existing tests.

🤖 Generated with Claude Code

When the AdverseSelectionTracker reports moderate adverse selection
(avg_<window> <= --auto-exclude-threshold-bps, default -3.0) for
--auto-exclude-consecutive summary windows in a row, automatically
pause the coin for --auto-exclude-cooldown seconds. The pause uses
the existing _coin_cooldown_until map so it composes with the existing
loss_streak cooldown without extra plumbing.

This complements two existing defenses:
  - loss_streak_limit catches rapid sequences of losing closes but
    misses slow bleed from many small losses.
  - dynamic_offset widens the offset by up to +3 bps but cannot stop
    a coin that is structurally adverse-selected.

The new feature watches the markout signal (mid-price drift after
each fill) and reacts before either of the above would notice.

Implementation details:
  - New AutoExcludeConfig dataclass on MMConfig with __post_init__
    validation (consecutive >= 1, min_fills >= 1, cooldown > 0,
    window_label in 5s/30s/60s).
  - AdverseSelectionTracker now records each summary window into a
    per-coin deque (cap 10) and exposes get_recent_windows(coin, n).
    Snapshots include the window timestamp, fill count, and avg_*
    figures used by the strategy.
  - MarketMakingStrategy._check_auto_exclude(coin) is invoked from
    run() just before the cooldown skip; defensive against tests that
    bypass __init__.
  - The shared cooldown deadline check in run() is no longer gated on
    loss_streak_limit > 0, so auto-exclude alone can drive cooldowns
    via the same map.
  - Six new CLI flags wired through bot.py with dest=<config_key> per
    Phase 2b.

Tests:
  - tests/test_mm_config.py: AutoExcludeConfig defaults + four
    validation cases + extended from_legacy_dict coverage.
  - tests/test_adverse_selection_tracker.py: history append, get_recent_windows
    last-n / unknown-coin / n<=0 / cap-at-MAX_HISTORY / avg=None semantics.
  - tests/test_adverse_auto_exclude.py (new): trigger / borderline /
    threshold-exact / short history / min_fills / None-avg / 30s window
    label / cooldown lifecycle / shared cooldown coexistence /
    constructor validation propagation.

Default-disabled, so existing deployments are unaffected. Requires
--enable-adverse-selection-log.

@keitaj keitaj left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat: Auto-exclude coin on consecutive adverse-selection windows

Self-contained feature that uses existing AdverseSelectionTracker infrastructure. Validation, history capping, cooldown sharing with loss_streak, and the test matrix all line up with the design doc. Default-disabled, so deployment risk is minimal.

A couple of nits, no blockers.


1. Reacquiring the lock per coin in _log_summary (nit)

ws/adverse_selection_tracker.py L222-223:

for coin in sorted(aggregates.keys()):
    ...
    with self._lock:
        self._history[coin].append(snapshot)

The lock is acquired once per coin inside the loop. With ~10–20 coins per summary the overhead is negligible, but the pattern is slightly off — every other path in the file batches under a single with self._lock:. Hoisting the lock outside the loop both reads better and is consistent.

Suggestion: Move the with self._lock: to wrap the whole for coin in sorted(...) block, or build a list of (coin, snapshot) pairs first and append them all under one lock.


2. Cooldown-expired log doesnt identify which feature triggered it (nit)

strategies/market_making_strategy.py L401-407:

When the deadline is reached the message is "[mm] {coin} cooldown expired, resuming" — fine, but with two writers to _coin_cooldown_until (loss_streak and auto_exclude), its no longer obvious from the log which feature paused the coin in the first place. The trigger-time log already names the source (hit N consecutive losses vs auto-excluded: ...), so this only matters for the resume side.

Suggestion: Optional. Track the trigger source alongside the deadline (e.g. _coin_cooldown_source: Dict[str, str]) and include it in the resume log. Easy follow-up if it ever matters in the field.


3. Disabled-path tests do not assert the tracker was untouched (nit)

tests/test_adverse_auto_exclude.py TestDisabled:

The two disabled-feature tests check that no cooldown was set, but dont verify that tracker.get_recent_windows wasnt called at all. Strengthening to tracker.get_recent_windows.assert_not_called() would catch a future refactor that accidentally moves the disable check below the tracker query.

Suggestion: Optional. Add the assertion in test_disabled_does_nothing.


Other observations (no action needed)

  • Cooldown skip ungating: removing if self.loss_streak_limit > 0: around the cooldown deadline check is correct now that auto_exclude also writes to the same map. Existing loss_streak callers are preserved because the inner reset (_loss_streaks[coin] = 0) is now guarded on loss_streak_limit > 0. ✅
  • getattr(self, cfg, None) defensive guard: matches the bypass-__init__ test pattern used elsewhere in the suite. ✅
  • Threshold semantics: avg > threshold early-returns, so avg == threshold triggers — the exact-match test pins this down explicitly. ✅
  • Re-trigger after cooldown expires: by design, if the same N adverse windows are still in the deque after the cooldown elapses, the coin will be re-excluded on the next _check_auto_exclude. This matches the "structurally adverse coin" use case and is consistent with the design doc. ✅
  • History cap test: the MAX_HISTORY + 3 scenario verifies FIFO eviction with the exact expected oldest/newest values — strong assertion that catches real regressions. ✅

Verdict: LGTM

Three nits, all optional follow-ups. Phase 4 of the MM hardening arc is in good shape — merging on CI confirmation.

@keitaj
keitaj merged commit ead0de6 into main Apr 28, 2026
6 checks passed
@keitaj
keitaj deleted the feat/adverse-auto-exclude branch April 28, 2026 01:20
keitaj added a commit that referenced this pull request Apr 29, 2026
The module-level docstring still described mm_config as Phase 1 with
four sub-configs, but Phases 2–3 (PRs #129, #130, #131) and the
auto-exclude work (PR #132) added six more groups. Rewrite the opening
paragraph so it reflects the current set without naming a specific
phase that will keep going stale.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant