Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<window>` 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)
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)')
Expand Down Expand Up @@ -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',
],
}
Expand Down
189 changes: 189 additions & 0 deletions strategies/coin_health_tracker.py
Original file line number Diff line number Diff line change
@@ -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()}
Loading
Loading