From fbe95dd0a83f9f3fdf3ea69819649e346eec0123 Mon Sep 17 00:00:00 2001 From: keitaj Date: Mon, 4 May 2026 10:39:04 +0900 Subject: [PATCH] feat: add Forager composite-score auto-exclude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the existing markout-based ``--auto-exclude`` with a multi-axis health score that captures coin-level pathologies the markout sensor misses: * **Activity** — fill recency, catches dead markets that never accumulate enough flow for the markout window to fire. * **Close quality** — maker-close rate, catches structural taker fallback even when markout looks neutral. * **Cost** — recent dollar-loss per 1K notional, catches gradual bleed. A rolling ``CoinHealthTracker`` records every fill (activity) and every position close (quality + cost) from the WS fill feed and computes a weighted composite in [0, 100]. Strategy ``_check_forager_health()`` samples the score on the existing per-coin run-loop tick; below ``--forager-threshold`` for ``--forager-consecutive`` checks in a row, the coin is paused via the shared ``_coin_cooldown_until`` map (same machinery as ``--auto-exclude`` and ``--loss-streak-limit``). Forager and ``--auto-exclude`` are independent — either may trigger. All thresholds, weights, and formula constants are sourced from ``ForagerConfig``; the tracker contains no hardcoded numeric tunables. The 7 most operationally relevant parameters are exposed as ``--forager-*`` CLI flags, and the 5 internal formula constants (window, idle grace, cost scale, min-closes gate, check interval) are overridable via env vars without bot.py CLI bloat. * New module: ``strategies/coin_health_tracker.py`` (CoinHealthTracker + CloseEvent / CoinHealth dataclasses). * ``strategies/mm_config.py``: new ``ForagerConfig`` dataclass wired into ``MMConfig`` via ``from_legacy_dict()``. * ``strategies/market_making_strategy.py``: instantiates the tracker on startup when ``forager_enabled``, calls ``_check_forager_health`` in the per-coin run loop right after ``_check_auto_exclude``, feeds ``record_fill`` from the in-loop fill detector. * ``ws/fill_feed.py``: routes WS userFills to ``record_fill`` (always) and ``record_close`` (when ``closedPnl != 0``) using ``crossed`` to derive maker/taker. * ``bot.py``: 7 new CLI flags, 12 new keys in ``default_configs`` and ``_STRATEGY_PARAMS``, link Forager into the FillFeed when enabled. * ``validation/strategy_validator.py``: range checks for the new params. * ``README.md``: human-readable section + AI YAML reference block. * Tests: ``test_coin_health_tracker.py`` (28 cases) for scoring math and config-driven tunability; ``test_forager.py`` for strategy integration including throttling, cooldown lifecycle, and co-existence with ``--auto-exclude``. Backward compatible: ``forager_enabled`` defaults to ``false``; with the flag absent the tracker is never instantiated and no fill feed hook fires. ``CoinHealthTracker`` always reads from ``ForagerConfig`` so env-only formula constants tune behaviour without code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 14 ++ bot.py | 55 ++++++ strategies/coin_health_tracker.py | 189 +++++++++++++++++++ strategies/market_making_strategy.py | 88 +++++++++ strategies/mm_config.py | 99 ++++++++++ tests/test_coin_health_tracker.py | 272 +++++++++++++++++++++++++++ tests/test_forager.py | 260 +++++++++++++++++++++++++ validation/strategy_validator.py | 47 +++++ ws/fill_feed.py | 42 +++++ 9 files changed, 1066 insertions(+) create mode 100644 strategies/coin_health_tracker.py create mode 100644 tests/test_coin_health_tracker.py create mode 100644 tests/test_forager.py diff --git a/README.md b/README.md index 3421903..be52867 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,8 @@ The `market_making` strategy uses **progressive close pricing**: as a position a **Auto-exclude on adverse selection** (`--auto-exclude`): Automatically pauses a coin when the AdverseSelectionTracker reports moderate adverse selection (`avg_` below `--auto-exclude-threshold-bps`, default `-3.0`) for `--auto-exclude-consecutive` summary windows in a row (default 3, ~15 min with the default 300s log interval). The coin is paused for `--auto-exclude-cooldown` seconds (default 1800) and then automatically resumes. Requires `--enable-adverse-selection-log`. Per-window `min_fills` filtering keeps low-volume noise from triggering. Shares the per-coin cooldown map with `--loss-streak-limit`, so the two features compose naturally. +**Forager: composite-score auto-exclude** (`--forager`): Complements `--auto-exclude` (markout-based) by scoring each coin on three independent dimensions and pausing it when the composite stays low. The dimensions are **activity** (recency of fills, catches dead markets like a coin that hasn't filled in hours), **close quality** (maker close rate, catches coins with structural taker fallback even when markout looks neutral), and **cost** (recent $/1K vol, catches gradual bleed). A weighted composite in `[0, 100]` is computed each cycle; below `--forager-threshold` (default 30) for `--forager-consecutive` checks (default 3) triggers `--forager-cooldown` seconds (default 1800) on the shared cooldown map. Weights are configurable via `--forager-w-activity`, `--forager-w-quality`, `--forager-w-cost`. Internal formula constants (window length, idle grace, cost scale, min-closes gate) can be overridden via env vars (`FORAGER_WINDOW_SECONDS`, `FORAGER_ACTIVITY_IDLE_MIN_SECONDS`, `FORAGER_COST_MAX_PER_1K`, `FORAGER_MIN_CLOSES_FOR_QUALITY`, `FORAGER_CHECK_INTERVAL_SECONDS`). Default disabled; both Forager and `--auto-exclude` may run side-by-side and either may set the cooldown. + **WebSocket guards** (require `--enable-ws`): - `--bbo-guard-threshold-bps`: Cancel stale entry orders when BBO moves (default: 2.0) - `--imbalance-guard-threshold`: Cancel one side when L2 book is skewed (0–1, default: 0) @@ -595,6 +597,18 @@ strategies: auto_exclude_min_fills: 3 # --auto-exclude-min-fills (per-window minimum fill count) auto_exclude_cooldown: 1800 # --auto-exclude-cooldown (pause seconds after trigger; auto-resume) auto_exclude_window_label: "60s" # --auto-exclude-window-label (5s|30s|60s tracker sample window) + forager_enabled: false # --forager (composite-score auto-exclude on activity + close-quality + cost) + forager_score_threshold: 30.0 # --forager-threshold (composite score below this triggers; 0-100) + forager_consecutive: 3 # --forager-consecutive (consecutive sub-threshold checks to trigger) + forager_cooldown_seconds: 1800 # --forager-cooldown (pause seconds after trigger; auto-resume) + forager_weight_activity: 0.3 # --forager-w-activity (composite weight for activity dimension) + forager_weight_quality: 0.4 # --forager-w-quality (composite weight for close-quality dimension) + forager_weight_cost: 0.3 # --forager-w-cost (composite weight for cost dimension) + forager_window_seconds: 1800.0 # env-only (rolling window for fill activity + close history) + forager_check_interval_seconds: 300.0 # env-only (per-coin throttle between health checks) + forager_activity_idle_min_seconds: 300.0 # env-only (idle grace before activity score decays) + forager_cost_max_per_1k: 0.6 # env-only ($/1K at which cost score reaches 0) + forager_min_closes_for_quality: 5 # env-only (min closes required to trust quality dimension) account_cap_pct: 0.05 # --account-cap-pct max_positions: 3 take_profit_percent: 1 diff --git a/bot.py b/bot.py index 2a58a87..f082509 100644 --- a/bot.py +++ b/bot.py @@ -189,6 +189,22 @@ def __init__(self, strategy_name: str = "simple_ma", coins: Optional[List[str]] 'take_profit_percent': 1, 'stop_loss_percent': 2, 'account_cap_pct': 0.05, + # Forager (composite-score auto-exclude). Defaults match the + # ForagerConfig dataclass; values can be overridden via env + # vars (e.g. FORAGER_WINDOW_SECONDS=3600) without adding a + # CLI flag for every internal formula constant. + 'forager_enabled': False, + 'forager_score_threshold': 30.0, + 'forager_consecutive': 3, + 'forager_cooldown_seconds': 1800, + 'forager_weight_activity': 0.3, + 'forager_weight_quality': 0.4, + 'forager_weight_cost': 0.3, + 'forager_window_seconds': 1800.0, + 'forager_check_interval_seconds': 300.0, + 'forager_activity_idle_min_seconds': 300.0, + 'forager_cost_max_per_1k': 0.6, + 'forager_min_closes_for_quality': 5, } } @@ -499,6 +515,17 @@ def run(self) -> None: self.strategy._adverse_tracker = self.adverse_tracker logger.info("[ws] Dynamic offset linked to AdverseSelectionTracker") + # Forager: route fill events from FillFeed into the strategy's + # CoinHealthTracker so the quality + cost dimensions are populated. + coin_health_tracker = getattr(self.strategy, '_coin_health_tracker', None) + if ( + self.fill_feed is not None + and coin_health_tracker is not None + and self.strategy_config.get('forager_enabled', False) + ): + self.fill_feed.set_coin_health_tracker(coin_health_tracker) + logger.info("[ws] Forager linked to FillFeed (CoinHealthTracker)") + if self._enable_ws and self.ws_feed is not None: self._ws_reconnector = WsReconnector(stale_threshold=60.0) @@ -1144,6 +1171,27 @@ def stop(self): help='Adverse-selection sample window for auto-exclude: 5s|30s|60s ' '(default: 60s, market_making)') + # Forager: composite-score auto-exclude (orthogonal to auto_exclude) + parser.add_argument('--forager', dest='forager_enabled', + action='store_true', default=False, + help='Auto-exclude a coin when its composite health score (activity + close ' + 'maker rate + cost) stays below --forager-threshold for ' + '--forager-consecutive checks in a row (market_making)') + parser.add_argument('--forager-threshold', dest='forager_score_threshold', type=float, + help='Composite health score threshold (0-100); below this triggers ' + '(default: 30.0, market_making)') + parser.add_argument('--forager-consecutive', type=int, + help='Consecutive sub-threshold checks required to trigger forager exclude ' + '(default: 3, market_making)') + parser.add_argument('--forager-cooldown', dest='forager_cooldown_seconds', type=int, + help='Cooldown seconds after forager triggers (default: 1800, market_making)') + parser.add_argument('--forager-w-activity', dest='forager_weight_activity', type=float, + help='Composite-score weight for activity dimension (default: 0.3, market_making)') + parser.add_argument('--forager-w-quality', dest='forager_weight_quality', type=float, + help='Composite-score weight for close-quality dimension (default: 0.4, market_making)') + parser.add_argument('--forager-w-cost', dest='forager_weight_cost', type=float, + help='Composite-score weight for cost dimension (default: 0.3, market_making)') + # Risk guardrail parameters parser.add_argument('--max-position-pct', type=float, help='Max single position as %% of account (default: 0.2)') @@ -1265,6 +1313,13 @@ def stop(self): 'auto_exclude_min_fills', 'auto_exclude_cooldown', 'auto_exclude_window_label', + 'forager_enabled', + 'forager_score_threshold', + 'forager_consecutive', + 'forager_cooldown_seconds', + 'forager_weight_activity', + 'forager_weight_quality', + 'forager_weight_cost', 'drain_flag_file', ], } diff --git a/strategies/coin_health_tracker.py b/strategies/coin_health_tracker.py new file mode 100644 index 0000000..bdd0be3 --- /dev/null +++ b/strategies/coin_health_tracker.py @@ -0,0 +1,189 @@ +"""Per-coin health tracking and composite scoring for the Forager feature. + +This module owns the rolling per-coin observation buffers (recent close +events plus last-fill timestamp) and computes a composite health score +in [0, 100]. The score is consumed by +``MarketMakingStrategy._check_forager_health`` to auto-pause coins that +are unfit for market making (no fills, low maker-rate close, or high +cost). + +All thresholds, weights, and formula constants are read from +``ForagerConfig`` — the module contains no hardcoded numeric tunables. +""" + +import logging +import threading +import time +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import TYPE_CHECKING, Deque, Dict, Optional + +if TYPE_CHECKING: + from strategies.mm_config import ForagerConfig + +logger = logging.getLogger(__name__) + + +@dataclass +class CloseEvent: + """A single position close event used to compute coin health.""" + + timestamp: float # monotonic + is_maker: bool # True if close filled as maker + net_pnl: float # closed_pnl - fee + notional: float # size * price + + +@dataclass +class CoinHealth: + """Aggregated per-coin health metrics over the rolling window.""" + + activity_score: float # 0-100, 100 = recent fill activity + close_quality_score: float # 0-100, 100 = all maker closes + cost_score: float # 0-100, 100 = $/1K vol very low + composite_score: float # weighted sum of the three + n_closes: int # closes in window + last_fill_age: float # seconds since last recorded fill + + +class CoinHealthTracker: + """Tracks per-coin close events and computes a rolling health score. + + Thread-safe via a single lock. Used by Forager to detect coins that + are filling poorly, bleeding cost, or completely inactive. All tuning + parameters are sourced from the supplied ``ForagerConfig`` — there are + no hardcoded constants in the scoring formulas, in line with the + project policy of reading tunables via ``config.get('key', default)``. + """ + + # Score returned for the quality / cost dimensions when there is no + # recent close history. Bias to the neutral midpoint avoids both + # false-trigger (would happen at 0) and never-trigger (at 100). + _NO_HISTORY_NEUTRAL_SCORE: float = 50.0 + + def __init__( + self, + config: "ForagerConfig", + max_events_per_coin: int = 200, + ) -> None: + self.config = config + self.max_events_per_coin = max_events_per_coin + self._closes: Dict[str, Deque[CloseEvent]] = defaultdict( + lambda: deque(maxlen=max_events_per_coin) + ) + self._last_fill_at: Dict[str, float] = {} + self._lock = threading.Lock() + + # ------------------------------------------------------------------ # + # Recorders + # ------------------------------------------------------------------ # + + def record_fill(self, coin: str, ts: Optional[float] = None) -> None: + """Update the last-fill timestamp for the activity dimension. + + Called on every fill (entry or close). Cheap; just updates a dict + entry under the lock. + """ + ts = ts if ts is not None else time.monotonic() + with self._lock: + self._last_fill_at[coin] = ts + + def record_close( + self, + coin: str, + is_maker: bool, + net_pnl: float, + notional: float, + ts: Optional[float] = None, + ) -> None: + """Append a position close event to the rolling buffer.""" + ts = ts if ts is not None else time.monotonic() + with self._lock: + self._closes[coin].append( + CloseEvent(timestamp=ts, is_maker=is_maker, + net_pnl=net_pnl, notional=notional) + ) + self._last_fill_at[coin] = ts + + # ------------------------------------------------------------------ # + # Score computation + # ------------------------------------------------------------------ # + + def get_health(self, coin: str) -> CoinHealth: + """Compute the current composite health score for ``coin``. + + All thresholds and weights come from ``self.config``; no literals + in this method's score formulas. + """ + cfg = self.config + now = time.monotonic() + cutoff = now - cfg.window_seconds + with self._lock: + recent = [e for e in self._closes[coin] if e.timestamp >= cutoff] + last_fill = self._last_fill_at.get(coin, 0.0) + + last_fill_age = (now - last_fill) if last_fill > 0 else float('inf') + activity = self._activity_score(last_fill_age, cfg) + + n = len(recent) + if n == 0: + neutral = self._NO_HISTORY_NEUTRAL_SCORE + composite = ( + activity * cfg.weight_activity + + neutral * cfg.weight_quality + + neutral * cfg.weight_cost + ) + return CoinHealth(activity, neutral, neutral, composite, 0, last_fill_age) + + quality = self._close_quality_score(recent) + cost = self._cost_score(recent, cfg) + composite = ( + activity * cfg.weight_activity + + quality * cfg.weight_quality + + cost * cfg.weight_cost + ) + return CoinHealth(activity, quality, cost, composite, n, last_fill_age) + + # ------------------------------------------------------------------ # + # Per-axis score helpers (kept private; tests exercise via get_health) + # ------------------------------------------------------------------ # + + @staticmethod + def _activity_score(last_fill_age: float, cfg: "ForagerConfig") -> float: + """Activity dimension: 100 inside the idle grace window, decays + linearly to 0 at ``window_seconds``.""" + idle_min = cfg.activity_idle_min_seconds + if last_fill_age <= idle_min: + return 100.0 + if last_fill_age >= cfg.window_seconds: + return 0.0 + decay_range = cfg.window_seconds - idle_min + return max(0.0, 100.0 * (1 - (last_fill_age - idle_min) / decay_range)) + + @staticmethod + def _close_quality_score(events) -> float: + """Close-quality dimension: maker close rate × 100.""" + n = len(events) + if n == 0: + return 0.0 + n_maker = sum(1 for e in events if e.is_maker) + return 100.0 * n_maker / n + + @staticmethod + def _cost_score(events, cfg: "ForagerConfig") -> float: + """Cost dimension: 100 at $/1K = 0, 0 at ``cost_max_per_1k``.""" + total_notional = sum(e.notional for e in events) + if total_notional <= 0: + return CoinHealthTracker._NO_HISTORY_NEUTRAL_SCORE + total_loss = sum(abs(e.net_pnl) for e in events if e.net_pnl < 0) + cost_per_1k = total_loss / (total_notional / 1000.0) + return max(0.0, 100.0 * (1 - cost_per_1k / cfg.cost_max_per_1k)) + + # ------------------------------------------------------------------ # + # Inspection helpers (used by the strategy log + tests) + # ------------------------------------------------------------------ # + + def tracked_coins(self) -> Dict[str, int]: + """Return ``{coin: n_closes_in_buffer}`` snapshot for diagnostics.""" + with self._lock: + return {c: len(d) for c, d in self._closes.items()} diff --git a/strategies/market_making_strategy.py b/strategies/market_making_strategy.py index d8a430b..f374bf3 100644 --- a/strategies/market_making_strategy.py +++ b/strategies/market_making_strategy.py @@ -24,6 +24,7 @@ parse_coin_overrides, parse_spread_schedule, ) +from strategies.coin_health_tracker import CoinHealthTracker from strategies.mm_order_tracker import OrderTracker from strategies.mm_position_closer import PositionCloser from coin_utils import parse_coin @@ -125,6 +126,25 @@ def __init__(self, market_data_manager, order_manager, config: Dict) -> None: f"cooldown={self.cfg.auto_exclude.cooldown_seconds}s" ) + # ---- Forager: composite per-coin health scoring ---- # + self._coin_health_tracker: Optional[CoinHealthTracker] = ( + CoinHealthTracker(self.cfg.forager) if self.cfg.forager.enabled else None + ) + # Per-coin sliding history of recent composite scores (size = consecutive) + self._forager_score_history: Dict[str, deque] = defaultdict(deque) + # Per-coin throttle for `_check_forager_health` (avoid evaluating every loop) + self._forager_last_check: Dict[str, float] = {} + if self.cfg.forager.enabled: + logger.info( + f"[mm] Forager armed: threshold={self.cfg.forager.score_threshold}, " + f"consecutive={self.cfg.forager.consecutive}, " + f"weights=(act={self.cfg.forager.weight_activity}, " + f"qual={self.cfg.forager.weight_quality}, " + f"cost={self.cfg.forager.weight_cost}), " + f"window={self.cfg.forager.window_seconds}s, " + f"cooldown={self.cfg.forager.cooldown_seconds}s" + ) + # ---- Per-coin offset/spread/size overrides (aliases of self.cfg.per_coin) ---- # self._coin_offset_overrides: Dict[str, float] = self.cfg.per_coin.offset self._coin_spread_overrides: Dict[str, float] = self.cfg.per_coin.spread @@ -271,6 +291,10 @@ def run(self, coins: List[str]) -> None: for coin in new_fills: self._fills_detected += 1 self._fills_per_coin[coin] += 1 + # Forager: update activity dimension on entry fills. + tracker = getattr(self, '_coin_health_tracker', None) + if tracker is not None: + tracker.record_fill(coin) # Cancel opposite-side orders for newly filled coins to prevent # double-filling which doubles adverse selection cost. # NOTE: Does not fire when close_immediately=True because @@ -452,6 +476,9 @@ def run(self, coins: List[str]) -> None: # selection has been moderate for ``consecutive`` summary # windows in a row. self._check_auto_exclude(coin) + # Forager: composite health score (activity + maker rate + cost) + # Independent of auto-exclude; either may set the cooldown. + self._check_forager_health(coin) # Per-coin cooldown (shared by loss_streak and auto_exclude) cooldown_deadline = self._coin_cooldown_until.get(coin) @@ -1142,6 +1169,67 @@ def _check_auto_exclude(self, coin: str) -> None: f"(min_fills={cfg.min_fills}) → cooldown {cfg.cooldown_seconds}s" ) + def _check_forager_health(self, coin: str) -> None: + """Forager: composite health-score-based auto-exclude. + + Runs alongside ``_check_auto_exclude`` (markout-based) — both + write to the shared ``_coin_cooldown_until`` map. No-op when + the feature is disabled, the coin is already in cooldown, or + the per-coin throttle hasn't elapsed. + """ + cfg_root = getattr(self, 'cfg', None) + if cfg_root is None: + return + cfg = cfg_root.forager + tracker = getattr(self, '_coin_health_tracker', None) + if not cfg.enabled or tracker is None: + return + + # Already cooling down (auto_exclude or prior forager trigger) — skip. + deadline = self._coin_cooldown_until.get(coin) + now = time.monotonic() + if deadline and deadline > now: + return + + # Throttle: skip if checked recently for this coin. + last_check = self._forager_last_check.get(coin, 0.0) + if now - last_check < cfg.check_interval_seconds: + return + self._forager_last_check[coin] = now + + health = tracker.get_health(coin) + # Append to the consecutive-low history; trim to ``consecutive``. + history = self._forager_score_history[coin] + history.append(health.composite_score) + while len(history) > cfg.consecutive: + history.popleft() + + if len(history) < cfg.consecutive: + return # not enough samples yet + + if not all(s < cfg.score_threshold for s in history): + return + + # Avoid false positives on coins that are active but lack close + # history yet (quality dimension undefined). + if ( + health.n_closes < cfg.min_closes_for_quality + and health.activity_score > 50.0 + ): + return + + deadline = now + cfg.cooldown_seconds + self._coin_cooldown_until[coin] = deadline + history.clear() # reset; avoid immediate re-trigger after cooldown + logger.warning( + f"[mm] {coin} forager-excluded: composite_score=" + f"{health.composite_score:.1f} (activity={health.activity_score:.0f}, " + f"quality={health.close_quality_score:.0f}, " + f"cost={health.cost_score:.0f}, n_closes={health.n_closes}) " + f"< {cfg.score_threshold} for {cfg.consecutive} checks → " + f"cooldown {cfg.cooldown_seconds}s" + ) + def _get_hourly_spread_multiplier(self) -> float: """Get spread multiplier for current UTC hour. Returns 1.0 if no schedule.""" if not self._spread_schedule: diff --git a/strategies/mm_config.py b/strategies/mm_config.py index ac28fda..52222c1 100644 --- a/strategies/mm_config.py +++ b/strategies/mm_config.py @@ -301,6 +301,84 @@ def __post_init__(self) -> None: ) +@dataclass +class ForagerConfig: + """Multi-axis coin health scoring for auto-exclude. + + Complements :class:`AutoExcludeConfig` (markout-based) with three + additional signals: activity (fill frequency), close-quality + (maker rate), and cost ($/1K vol). The composite score (0-100, + higher is healthier) drops below ``score_threshold`` for + ``consecutive`` consecutive checks → coin is paused via the existing + ``_coin_cooldown_until`` map shared with :class:`AutoExcludeConfig` + and :class:`LossStreakConfig`. + + Defaults match ``docs/design-doc/20260504_forager_coin_health.md``. + """ + + enabled: bool = False + score_threshold: float = 30.0 + consecutive: int = 3 + cooldown_seconds: int = 1800 + weight_activity: float = 0.3 + weight_quality: float = 0.4 + weight_cost: float = 0.3 + # env-only knobs (no CLI flag) — formula constants, rarely tuned + window_seconds: float = 1800.0 + check_interval_seconds: float = 300.0 + activity_idle_min_seconds: float = 300.0 + cost_max_per_1k: float = 0.6 + min_closes_for_quality: int = 5 + + def __post_init__(self) -> None: + if not 0.0 <= self.score_threshold <= 100.0: + raise ValueError( + f"forager_score_threshold must be in [0, 100], got {self.score_threshold}" + ) + if self.consecutive < 1: + raise ValueError( + f"forager_consecutive must be >= 1, got {self.consecutive}" + ) + if self.cooldown_seconds <= 0: + raise ValueError( + f"forager_cooldown_seconds must be > 0, got {self.cooldown_seconds}" + ) + for name, val in ( + ("weight_activity", self.weight_activity), + ("weight_quality", self.weight_quality), + ("weight_cost", self.weight_cost), + ): + if not 0.0 <= val <= 1.0: + raise ValueError(f"forager_{name} must be in [0, 1], got {val}") + if self.window_seconds <= 0: + raise ValueError( + f"forager_window_seconds must be > 0, got {self.window_seconds}" + ) + if self.check_interval_seconds < 0: + raise ValueError( + f"forager_check_interval_seconds must be >= 0, got {self.check_interval_seconds}" + ) + if self.activity_idle_min_seconds < 0: + raise ValueError( + f"forager_activity_idle_min_seconds must be >= 0, " + f"got {self.activity_idle_min_seconds}" + ) + if self.activity_idle_min_seconds >= self.window_seconds: + raise ValueError( + f"forager_activity_idle_min_seconds ({self.activity_idle_min_seconds}) " + f"must be < window_seconds ({self.window_seconds})" + ) + if self.cost_max_per_1k <= 0: + raise ValueError( + f"forager_cost_max_per_1k must be > 0, got {self.cost_max_per_1k}" + ) + if self.min_closes_for_quality < 1: + raise ValueError( + f"forager_min_closes_for_quality must be >= 1, " + f"got {self.min_closes_for_quality}" + ) + + @dataclass class MMConfig: """Root config for ``MarketMakingStrategy``. @@ -320,6 +398,7 @@ class MMConfig: dynamic_offset: DynamicOffsetConfig = field(default_factory=DynamicOffsetConfig) dynamic_age: DynamicAgeConfig = field(default_factory=DynamicAgeConfig) auto_exclude: AutoExcludeConfig = field(default_factory=AutoExcludeConfig) + forager: ForagerConfig = field(default_factory=ForagerConfig) @classmethod def from_legacy_dict(cls, d: Dict) -> "MMConfig": @@ -392,4 +471,24 @@ def from_legacy_dict(cls, d: Dict) -> "MMConfig": cooldown_seconds=int(d.get('auto_exclude_cooldown', 1800)), window_label=str(d.get('auto_exclude_window_label', '60s')), ), + forager=ForagerConfig( + enabled=bool(d.get('forager_enabled', False)), + score_threshold=float(d.get('forager_score_threshold', 30.0)), + consecutive=int(d.get('forager_consecutive', 3)), + cooldown_seconds=int(d.get('forager_cooldown_seconds', 1800)), + weight_activity=float(d.get('forager_weight_activity', 0.3)), + weight_quality=float(d.get('forager_weight_quality', 0.4)), + weight_cost=float(d.get('forager_weight_cost', 0.3)), + window_seconds=float(d.get('forager_window_seconds', 1800.0)), + check_interval_seconds=float( + d.get('forager_check_interval_seconds', 300.0) + ), + activity_idle_min_seconds=float( + d.get('forager_activity_idle_min_seconds', 300.0) + ), + cost_max_per_1k=float(d.get('forager_cost_max_per_1k', 0.6)), + min_closes_for_quality=int( + d.get('forager_min_closes_for_quality', 5) + ), + ), ) diff --git a/tests/test_coin_health_tracker.py b/tests/test_coin_health_tracker.py new file mode 100644 index 0000000..8beb9a3 --- /dev/null +++ b/tests/test_coin_health_tracker.py @@ -0,0 +1,272 @@ +"""Unit tests for ``strategies.coin_health_tracker.CoinHealthTracker``. + +Verifies that the composite health score is driven entirely by +``ForagerConfig`` (no hardcoded literals in the formulas) so operators +can tune the scoring via env vars without touching code. +""" + +import pytest + +from strategies import coin_health_tracker as cht_mod +from strategies.coin_health_tracker import CoinHealthTracker +from strategies.mm_config import ForagerConfig + + +def _config(**overrides) -> ForagerConfig: + """Build a ForagerConfig with the supplied overrides applied to defaults.""" + defaults = dict( + enabled=True, + score_threshold=30.0, + consecutive=3, + cooldown_seconds=1800, + weight_activity=0.3, + weight_quality=0.4, + weight_cost=0.3, + window_seconds=1800.0, + check_interval_seconds=300.0, + activity_idle_min_seconds=300.0, + cost_max_per_1k=0.6, + min_closes_for_quality=5, + ) + defaults.update(overrides) + return ForagerConfig(**defaults) + + +def _patch_clock(monkeypatch, t: float) -> dict: + """Install a controllable monotonic clock on the cht module's ``time``.""" + state = {"t": t} + monkeypatch.setattr(cht_mod.time, "monotonic", lambda: state["t"]) + return state + + +# --------------------------------------------------------------------------- # +# Activity dimension +# --------------------------------------------------------------------------- # + + +class TestActivityScore: + def test_activity_full_when_no_fill_recorded_then_fill(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + tracker.record_fill("BTC") + assert tracker.get_health("BTC").activity_score == 100.0 + + def test_activity_full_within_idle_min(self, monkeypatch): + clock = _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config(activity_idle_min_seconds=300.0)) + tracker.record_fill("BTC") + clock["t"] += 200.0 # within idle grace + assert tracker.get_health("BTC").activity_score == 100.0 + + def test_activity_zero_after_window_elapsed(self, monkeypatch): + clock = _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker( + _config(window_seconds=1800.0, activity_idle_min_seconds=300.0) + ) + tracker.record_fill("BTC") + clock["t"] += 1800.0 # exactly at window edge + assert tracker.get_health("BTC").activity_score == 0.0 + + def test_activity_linear_decay(self, monkeypatch): + clock = _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker( + _config(window_seconds=1800.0, activity_idle_min_seconds=300.0) + ) + tracker.record_fill("BTC") + # midpoint: idle = 300 + (1800-300)/2 = 1050s -> activity = 50 + clock["t"] += 1050.0 + assert abs(tracker.get_health("BTC").activity_score - 50.0) < 0.001 + + def test_activity_zero_for_unknown_coin(self): + tracker = CoinHealthTracker(_config()) + assert tracker.get_health("UNSEEN").activity_score == 0.0 + + def test_activity_idle_min_is_config_driven(self, monkeypatch): + """If activity_idle_min_seconds=600, 500s idle is still in grace.""" + clock = _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker( + _config(activity_idle_min_seconds=600.0, window_seconds=1800.0) + ) + tracker.record_fill("BTC") + clock["t"] += 500.0 + assert tracker.get_health("BTC").activity_score == 100.0 + + +# --------------------------------------------------------------------------- # +# Close-quality dimension +# --------------------------------------------------------------------------- # + + +class TestCloseQualityScore: + def test_quality_neutral_with_no_closes(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + h = tracker.get_health("BTC") + assert h.close_quality_score == 50.0 + assert h.cost_score == 50.0 + + def test_quality_100_all_maker(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + for _ in range(5): + tracker.record_close("BTC", is_maker=True, net_pnl=0.01, notional=100) + assert tracker.get_health("BTC").close_quality_score == 100.0 + + def test_quality_0_all_taker(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + for _ in range(5): + tracker.record_close("BTC", is_maker=False, net_pnl=-0.05, notional=100) + assert tracker.get_health("BTC").close_quality_score == 0.0 + + def test_quality_50_half_and_half(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + for is_maker in (True, True, False, False): + tracker.record_close("BTC", is_maker=is_maker, net_pnl=0, notional=100) + assert tracker.get_health("BTC").close_quality_score == 50.0 + + +# --------------------------------------------------------------------------- # +# Cost dimension +# --------------------------------------------------------------------------- # + + +class TestCostScore: + def test_cost_100_when_all_profitable(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + for _ in range(5): + tracker.record_close("BTC", is_maker=True, net_pnl=0.05, notional=100) + # No losses → cost score = 100 + assert tracker.get_health("BTC").cost_score == 100.0 + + def test_cost_zero_at_cost_max(self, monkeypatch): + """Cost score should reach 0 exactly when $/1K hits ``cost_max_per_1k``.""" + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config(cost_max_per_1k=0.6)) + # 5 closes × notional 100 = 500 total notional. + # Loss 0.30 across 5 → $/1K = 0.30 / 0.5 = 0.6 → score 0. + for _ in range(5): + tracker.record_close("BTC", is_maker=False, net_pnl=-0.06, notional=100) + h = tracker.get_health("BTC") + assert abs(h.cost_score) < 0.001 + + def test_cost_max_per_1k_is_config_driven(self, monkeypatch): + """Same loss profile but cost_max_per_1k=0.3 → score 0 reaches sooner.""" + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config(cost_max_per_1k=0.3)) + # $/1K = 0.6 with default; with cost_max_per_1k=0.3, score should + # also be 0 (clamped at 0, not negative). + for _ in range(5): + tracker.record_close("BTC", is_maker=False, net_pnl=-0.06, notional=100) + assert tracker.get_health("BTC").cost_score == 0.0 + + +# --------------------------------------------------------------------------- # +# Composite score: weights wiring and config-driven behaviour +# --------------------------------------------------------------------------- # + + +class TestCompositeScore: + def test_composite_with_default_weights(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + # All three dimensions at 100 → composite at 100 (weights sum to 1.0). + tracker.record_fill("BTC") + for _ in range(5): + tracker.record_close("BTC", is_maker=True, net_pnl=0.01, notional=100) + h = tracker.get_health("BTC") + assert h.activity_score == 100.0 + assert h.close_quality_score == 100.0 + assert h.cost_score == 100.0 + assert abs(h.composite_score - 100.0) < 0.001 + + def test_composite_responds_to_custom_weights(self, monkeypatch): + """Composite must honour the supplied weights, not hardcoded 0.3/0.4/0.3.""" + _patch_clock(monkeypatch, 1000.0) + cfg = _config(weight_activity=1.0, weight_quality=0.0, weight_cost=0.0) + tracker = CoinHealthTracker(cfg) + tracker.record_fill("BTC") + for _ in range(5): + # Quality=0, cost should be near 0 with these losses. + tracker.record_close("BTC", is_maker=False, net_pnl=-0.04, notional=100) + h = tracker.get_health("BTC") + # composite = activity (100) since quality/cost weights are 0. + assert abs(h.composite_score - 100.0) < 0.001 + + +# --------------------------------------------------------------------------- # +# Window expiry +# --------------------------------------------------------------------------- # + + +class TestWindowExpiry: + def test_old_events_excluded_from_score(self, monkeypatch): + """Events older than ``window_seconds`` must not affect the score.""" + clock = _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config(window_seconds=600.0)) + # Insert a close that will age out. + tracker.record_close("BTC", is_maker=False, net_pnl=-1.0, notional=100) + clock["t"] += 700.0 # now older than window + # Close just inside the window: + for _ in range(5): + tracker.record_close("BTC", is_maker=True, net_pnl=0.01, notional=100) + h = tracker.get_health("BTC") + # Quality should be 100 (only the recent 5 maker closes count). + assert h.close_quality_score == 100.0 + assert h.n_closes == 5 + + def test_max_events_per_coin_caps_buffer(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config(), max_events_per_coin=10) + for _ in range(50): + tracker.record_close("BTC", is_maker=True, net_pnl=0.01, notional=100) + # Buffer holds at most 10 even though 50 were appended. + assert tracker.get_health("BTC").n_closes == 10 + + +# --------------------------------------------------------------------------- # +# min_closes_for_quality is exposed but only consulted by the strategy gate; +# the tracker itself returns the raw quality score regardless. Verify so a +# future refactor doesn't accidentally bake the gate into the tracker. +# --------------------------------------------------------------------------- # + + +class TestQualityGateNotInTracker: + def test_quality_score_returned_even_with_one_close(self, monkeypatch): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config(min_closes_for_quality=10)) + tracker.record_close("BTC", is_maker=False, net_pnl=-0.5, notional=100) + # Even though 1 < min_closes_for_quality (10), tracker emits the + # actual quality score (0). The strategy gate is responsible for + # ignoring it; the tracker is unconditional. + h = tracker.get_health("BTC") + assert h.close_quality_score == 0.0 + assert h.n_closes == 1 + + +# --------------------------------------------------------------------------- # +# Decision-table parametrisation: matches the design doc's table +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "is_maker_pattern,net_pnls,expected_quality,expected_cost_low", + [ + ([True] * 5, [0.01] * 5, 100.0, False), # all maker, profitable + ([False] * 5, [-0.10] * 5, 0.0, True), # all taker, big loss → low cost score + ([True, False] * 3, [0.01, -0.01] * 3, 50.0, False), # half/half tiny loss + ], +) +def test_decision_table(monkeypatch, is_maker_pattern, net_pnls, expected_quality, expected_cost_low): + _patch_clock(monkeypatch, 1000.0) + tracker = CoinHealthTracker(_config()) + for is_maker, pnl in zip(is_maker_pattern, net_pnls): + tracker.record_close("BTC", is_maker=is_maker, net_pnl=pnl, notional=100) + h = tracker.get_health("BTC") + assert abs(h.close_quality_score - expected_quality) < 0.001 + if expected_cost_low: + assert h.cost_score < 30.0 + else: + assert h.cost_score >= 80.0 diff --git a/tests/test_forager.py b/tests/test_forager.py new file mode 100644 index 0000000..bb46216 --- /dev/null +++ b/tests/test_forager.py @@ -0,0 +1,260 @@ +"""Integration tests for the Forager auto-exclude path. + +The strategy initialises a ``CoinHealthTracker`` when +``forager_enabled=True`` and consults it via ``_check_forager_health`` +in the per-coin run loop. Tests here exercise the trigger / no-trigger +paths and the cooldown lifecycle. +""" + +import time +from unittest.mock import MagicMock + +from strategies import market_making_strategy as mm_mod +from strategies.coin_health_tracker import CoinHealth, CoinHealthTracker +from strategies.market_making_strategy import MarketMakingStrategy +from strategies.mm_config import ForagerConfig + + +def _make_strategy(**extra): + """Build a MarketMakingStrategy with Forager defaults applied.""" + market_data = MagicMock() + order_manager = MagicMock() + config = { + 'spread_bps': 10, + 'order_size_usd': 200, + 'max_open_orders': 4, + 'close_immediately': False, + 'max_positions': 8, + 'maker_only': True, + 'bbo_mode': True, + 'bbo_offset_bps': 1.0, + # Forager defaults; tests override score by stubbing tracker.get_health. + 'forager_enabled': True, + 'forager_score_threshold': 30.0, + 'forager_consecutive': 3, + 'forager_cooldown_seconds': 1800, + 'forager_weight_activity': 0.3, + 'forager_weight_quality': 0.4, + 'forager_weight_cost': 0.3, + 'forager_window_seconds': 1800.0, + 'forager_check_interval_seconds': 0.0, # disable throttle for tests + 'forager_activity_idle_min_seconds': 300.0, + 'forager_cost_max_per_1k': 0.6, + 'forager_min_closes_for_quality': 5, + **extra, + } + strategy = MarketMakingStrategy(market_data, order_manager, config) + return strategy + + +def _stub_health(score: float, n_closes: int = 10, activity: float = 50.0) -> CoinHealth: + return CoinHealth( + activity_score=activity, + close_quality_score=10.0, + cost_score=10.0, + composite_score=score, + n_closes=n_closes, + last_fill_age=120.0, + ) + + +# --------------------------------------------------------------------------- # +# Disabled path +# --------------------------------------------------------------------------- # + + +def test_disabled_no_op(): + s = _make_strategy(forager_enabled=False) + # When disabled, the tracker is None and the run-loop helper short-circuits. + assert s._coin_health_tracker is None + s._check_forager_health("BTC") + assert "BTC" not in s._coin_cooldown_until + + +# --------------------------------------------------------------------------- # +# Trigger / no-trigger paths +# --------------------------------------------------------------------------- # + + +def test_does_not_trigger_below_consecutive_count(monkeypatch): + s = _make_strategy(forager_consecutive=3) + s._coin_health_tracker = MagicMock() + s._coin_health_tracker.get_health.return_value = _stub_health(score=10.0) + # First two checks build the history but should not trigger. + s._check_forager_health("BTC") + s._check_forager_health("BTC") + assert "BTC" not in s._coin_cooldown_until + + +def test_triggers_after_consecutive_low_scores(monkeypatch): + s = _make_strategy(forager_consecutive=3) + s._coin_health_tracker = MagicMock() + s._coin_health_tracker.get_health.return_value = _stub_health(score=10.0) + # Three consecutive low scores → cooldown set. + s._check_forager_health("BTC") + s._check_forager_health("BTC") + s._check_forager_health("BTC") + assert "BTC" in s._coin_cooldown_until + assert s._coin_cooldown_until["BTC"] > time.monotonic() + + +def test_no_trigger_when_score_above_threshold(): + s = _make_strategy(forager_consecutive=3) + s._coin_health_tracker = MagicMock() + s._coin_health_tracker.get_health.return_value = _stub_health(score=80.0) + for _ in range(5): + s._check_forager_health("BTC") + assert "BTC" not in s._coin_cooldown_until + + +def test_intermittent_low_score_does_not_trigger(): + """Low scores must be CONSECUTIVE; one good score in between resets.""" + s = _make_strategy(forager_consecutive=3) + tracker = MagicMock() + s._coin_health_tracker = tracker + + # Pattern: low, low, high, low, low → no trigger because the high + # entry pushes the third "low" out of the consecutive window. + scores = [10.0, 10.0, 80.0, 10.0, 10.0] + for sc in scores: + tracker.get_health.return_value = _stub_health(score=sc) + s._check_forager_health("BTC") + assert "BTC" not in s._coin_cooldown_until + + +def test_skips_when_quality_data_insufficient_and_active(): + """Active coin with too few closes should not trigger (false-positive guard).""" + s = _make_strategy(forager_consecutive=3, forager_min_closes_for_quality=10) + tracker = MagicMock() + s._coin_health_tracker = tracker + # Activity high (>50), n_closes < min_closes_for_quality, score below threshold. + tracker.get_health.return_value = _stub_health(score=10.0, n_closes=2, activity=100.0) + for _ in range(5): + s._check_forager_health("BTC") + assert "BTC" not in s._coin_cooldown_until + + +def test_triggers_when_inactive_even_without_close_data(): + """Activity ≤ 50 means the coin is dead — quality gate doesn't protect it.""" + s = _make_strategy(forager_consecutive=3, forager_min_closes_for_quality=10) + tracker = MagicMock() + s._coin_health_tracker = tracker + # No fills (activity 0) and no closes — exactly the flx:COPPER case. + tracker.get_health.return_value = _stub_health(score=10.0, n_closes=0, activity=0.0) + s._check_forager_health("BTC") + s._check_forager_health("BTC") + s._check_forager_health("BTC") + assert "BTC" in s._coin_cooldown_until + + +# --------------------------------------------------------------------------- # +# Cooldown lifecycle +# --------------------------------------------------------------------------- # + + +def test_skips_evaluation_during_cooldown(): + s = _make_strategy() + # Manually set cooldown to ~5 minutes from now. + s._coin_cooldown_until["BTC"] = time.monotonic() + 300.0 + s._coin_health_tracker = MagicMock() + s._coin_health_tracker.get_health.return_value = _stub_health(score=10.0) + s._check_forager_health("BTC") + # Tracker should not be queried — early return on cooldown. + s._coin_health_tracker.get_health.assert_not_called() + + +def test_throttle_blocks_repeat_check_within_interval(monkeypatch): + s = _make_strategy(forager_check_interval_seconds=60.0, forager_consecutive=3) + tracker = MagicMock() + s._coin_health_tracker = tracker + tracker.get_health.return_value = _stub_health(score=10.0) + + # Control time so the second call is inside the throttle window. + state = {"t": 1000.0} + monkeypatch.setattr(mm_mod.time, "monotonic", lambda: state["t"]) + + s._check_forager_health("BTC") # records last_check + state["t"] += 30.0 # within 60s interval + s._check_forager_health("BTC") # should be throttled + state["t"] += 30.0 + s._check_forager_health("BTC") + # Only the first invocation got through; the second was throttled and + # the third is post-window — so we have at most 2 evaluations, not 3. + assert tracker.get_health.call_count < 3 + + +# --------------------------------------------------------------------------- # +# Co-existence with auto_exclude +# --------------------------------------------------------------------------- # + + +def test_does_not_re_trigger_if_auto_exclude_set_cooldown(): + """If auto_exclude already set the cooldown, Forager early-returns.""" + s = _make_strategy() + # Simulate auto_exclude setting cooldown. + s._coin_cooldown_until["BTC"] = time.monotonic() + 1800.0 + tracker = MagicMock() + s._coin_health_tracker = tracker + tracker.get_health.return_value = _stub_health(score=10.0) + s._check_forager_health("BTC") + s._check_forager_health("BTC") + s._check_forager_health("BTC") + # The cooldown was already set by the (mocked) auto_exclude — Forager + # should not modify it. + assert tracker.get_health.call_count == 0 + + +# --------------------------------------------------------------------------- # +# Defensive: bypass-init test fixtures should not crash +# --------------------------------------------------------------------------- # + + +def test_check_no_op_when_strategy_constructed_with_new(monkeypatch): + """Tests that bypass __init__ via __new__ should still be safe. + + Mirrors the defensive ``getattr(self, 'cfg', None)`` pattern used by + auto_exclude: those test fixtures don't set ``self.cfg`` at all. + """ + s = MarketMakingStrategy.__new__(MarketMakingStrategy) + # No attributes set; the helper must short-circuit cleanly. + s._check_forager_health("BTC") # must not raise + + +# --------------------------------------------------------------------------- # +# Config-driven tunability (anti-hardcode regression) +# --------------------------------------------------------------------------- # + + +def test_threshold_reads_from_config_not_hardcoded(monkeypatch): + """If we change ``forager_score_threshold`` to 50, score=40 should + trigger even though it would not at default 30.""" + s = _make_strategy(forager_consecutive=3, forager_score_threshold=50.0) + tracker = MagicMock() + s._coin_health_tracker = tracker + tracker.get_health.return_value = _stub_health(score=40.0) + s._check_forager_health("BTC") + s._check_forager_health("BTC") + s._check_forager_health("BTC") + assert "BTC" in s._coin_cooldown_until + + +def test_cooldown_seconds_reads_from_config(): + s = _make_strategy(forager_cooldown_seconds=42) + tracker = MagicMock() + s._coin_health_tracker = tracker + tracker.get_health.return_value = _stub_health(score=10.0) + before = time.monotonic() + for _ in range(3): + s._check_forager_health("BTC") + deadline = s._coin_cooldown_until["BTC"] + # Cooldown should be roughly 42s in the future, not 1800. + assert 30 < (deadline - before) < 60 + + +def test_tracker_uses_supplied_forager_config(): + """``CoinHealthTracker`` must use the strategy's ForagerConfig, not + its own hardcoded defaults.""" + custom_cfg = ForagerConfig(enabled=True, cost_max_per_1k=0.1) + tracker = CoinHealthTracker(custom_cfg) + assert tracker.config is custom_cfg + assert tracker.config.cost_max_per_1k == 0.1 diff --git a/validation/strategy_validator.py b/validation/strategy_validator.py index aa066ad..23cc293 100644 --- a/validation/strategy_validator.py +++ b/validation/strategy_validator.py @@ -246,6 +246,53 @@ def _validate_market_making(config: Dict) -> List[str]: errors.append(f"taker_fallback_age_seconds: expected number, got {type(val).__name__}") elif val < 0: errors.append(f"taker_fallback_age_seconds: must be >= 0, got {val}") + + # Forager: composite-score auto-exclude (defaults align with ForagerConfig). + if 'forager_score_threshold' in config: + val = config['forager_score_threshold'] + if not isinstance(val, (int, float)): + errors.append(f"forager_score_threshold: expected number, got {type(val).__name__}") + elif not 0.0 <= val <= 100.0: + errors.append(f"forager_score_threshold: must be in [0, 100], got {val}") + if 'forager_consecutive' in config: + errors += _positive_int('forager_consecutive', config['forager_consecutive']) + if 'forager_cooldown_seconds' in config: + errors += _positive_int('forager_cooldown_seconds', config['forager_cooldown_seconds']) + for w in ('forager_weight_activity', 'forager_weight_quality', 'forager_weight_cost'): + if w in config: + val = config[w] + if not isinstance(val, (int, float)): + errors.append(f"{w}: expected number, got {type(val).__name__}") + elif not 0.0 <= val <= 1.0: + errors.append(f"{w}: must be in [0, 1], got {val}") + if 'forager_window_seconds' in config: + errors += _positive('forager_window_seconds', config['forager_window_seconds']) + if 'forager_check_interval_seconds' in config: + val = config['forager_check_interval_seconds'] + if not isinstance(val, (int, float)): + errors.append( + f"forager_check_interval_seconds: expected number, got {type(val).__name__}" + ) + elif val < 0: + errors.append( + f"forager_check_interval_seconds: must be >= 0, got {val}" + ) + if 'forager_cost_max_per_1k' in config: + errors += _positive('forager_cost_max_per_1k', config['forager_cost_max_per_1k']) + if 'forager_min_closes_for_quality' in config: + errors += _positive_int( + 'forager_min_closes_for_quality', config['forager_min_closes_for_quality'] + ) + if 'forager_activity_idle_min_seconds' in config: + val = config['forager_activity_idle_min_seconds'] + if not isinstance(val, (int, float)): + errors.append( + f"forager_activity_idle_min_seconds: expected number, got {type(val).__name__}" + ) + elif val < 0: + errors.append( + f"forager_activity_idle_min_seconds: must be >= 0, got {val}" + ) return errors diff --git a/ws/fill_feed.py b/ws/fill_feed.py index db8dfd7..e20c09e 100644 --- a/ws/fill_feed.py +++ b/ws/fill_feed.py @@ -41,6 +41,7 @@ def __init__( self._error_count = 0 self._adverse_tracker: Any = None self._position_closer: Any = None + self._coin_health_tracker: Any = None def set_adverse_selection_tracker(self, tracker: Any) -> None: """Register an adverse selection tracker to receive fill notifications.""" @@ -55,6 +56,14 @@ def set_position_closer(self, closer: Any) -> None: """ self._position_closer = closer + def set_coin_health_tracker(self, tracker: Any) -> None: + """Register a CoinHealthTracker (Forager) to receive fill events. + + Activity is recorded on every fill; closes (``closedPnl != 0``) + also feed the quality / cost dimensions. + """ + self._coin_health_tracker = tracker + # ------------------------------------------------------------------ # # Lifecycle # ------------------------------------------------------------------ # @@ -155,6 +164,39 @@ def _on_fill(self, msg: Dict) -> None: except Exception as e: logger.debug("[ws-fill] Error notifying adverse tracker: %s", e) + # Notify Forager's CoinHealthTracker (observation only). + # Every fill updates activity; closes (closedPnl != 0) feed + # the quality + cost dimensions. + if self._coin_health_tracker is not None: + for fill in fills: + try: + coin = fill.get("coin", "") + if not coin: + continue + self._coin_health_tracker.record_fill(coin) + closed_pnl_raw = fill.get("closedPnl") + if closed_pnl_raw is None or float(closed_pnl_raw) == 0.0: + continue + # Treat fills with non-zero closedPnl as position closes. + px = float(fill.get("px", 0)) + sz = float(fill.get("sz", 0)) + crossed = bool(fill.get("crossed", False)) + fee = float(fill.get("fee", 0)) + if px <= 0 or sz <= 0: + continue + notional = sz * px + net_pnl = float(closed_pnl_raw) - fee + self._coin_health_tracker.record_close( + coin=coin, + is_maker=(not crossed), + net_pnl=net_pnl, + notional=notional, + ) + except Exception as e: + logger.debug( + "[ws-fill] Error notifying coin health tracker: %s", e + ) + # Cancel opposite-side orders for each filled coin. # OrderTracker.cancel_all_orders_for_coin is thread-safe (has its own lock). for coin in filled_coins: