feat: Auto-exclude coin on consecutive adverse-selection windows#132
Conversation
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
left a comment
There was a problem hiding this comment.
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 onloss_streak_limit > 0. ✅ getattr(self, cfg, None)defensive guard: matches the bypass-__init__test pattern used elsewhere in the suite. ✅- Threshold semantics:
avg > thresholdearly-returns, soavg == thresholdtriggers — 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 + 3scenario 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.
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>
Summary
AdverseSelectionTrackerreports moderate adverse selection (avg_<window>at or below--auto-exclude-threshold-bps, default-3.0) for--auto-exclude-consecutivesummary windows in a row (default 3, ~15 min at the default 300s log interval). The coin is paused for--auto-exclude-cooldownseconds (default 1800) and then automatically resumes.loss_streak_limit(close-PnL based, weak against slow bleed) anddynamic_offset(caps at +3 bps offset addition). Auto-exclude reacts to the mid-price drift signal directly and stops a coin that is structurally adverse-selected.AutoExcludeConfiggroup onMMConfig, with the legacy flat keys still accepted viafrom_legacy_dictand CLI flags wired withdest=<config_key>per refactor: Align argparse dest with config keys (Phase 2b) #130.Why
AdverseSelectionTrackeralready produces a per-coin[adverse] Summarylog every 300s. Until now the markout signal was observed only by humans (or bydynamic_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: newAutoExcludeConfigdataclass with__post_init__validation (consecutive ≥ 1, min_fills ≥ 1, cooldown > 0,window_label∈ {5s,30s,60s}). Added toMMConfigandfrom_legacy_dict.ws/adverse_selection_tracker.py: each_log_summarywindow also appends a snapshot (ts,fills,avg_5s/30s/60s) to a per-coin deque (capMAX_HISTORY = 10). Newget_recent_windows(coin, n)returns the last n entries.strategies/market_making_strategy.py: new_check_auto_exclude(coin)invoked fromrun()just before the cooldown skip. Defensivegetattr(self, 'cfg', None)so tests that bypass__init__continue to work. The cooldown deadline check inrun()is no longer gated onloss_streak_limit > 0, since auto-exclude alone can now drive cooldowns through the shared_coin_cooldown_untilmap.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 themarket_makingsection + AI YAML reference entries.Backward compatibility
--auto-excludetogether with--enable-adverse-selection-log.risk_manager.pyLevel 1 guardrails.Test plan
tests/test_mm_config.py: dataclass defaults + four validation cases + extendedfrom_legacy_dictcoverage.tests/test_adverse_selection_tracker.py: append-on-summary,get_recent_windowslast-n / unknown coin /n<=0/MAX_HISTORYcap /avg=Nonesemantics.tests/test_adverse_auto_exclude.py(new): trigger / borderline / threshold-exact / short history /min_fills/None-avg /30swindow label / cooldown lifecycle / shared cooldown coexistence / constructor validation propagation.flake8passes.pytestpasses (879 tests total, +28 new).🤖 Generated with Claude Code