diff --git a/README.md b/README.md index 1f0e577..7656b67 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ The `market_making` strategy uses **progressive close pricing**: as a position a **BBO mode** (`--bbo-mode`): Places orders at the best bid/ask instead of `mid ± spread_bps`. On Hyperliquid, market spreads are typically 0.1–2 bps, so even `SPREAD_BPS=5` places orders 4–5 bps away from BBO, resulting in low fill rates. BBO mode improves fill rates by tracking the current best prices. Use `--bbo-offset-bps N` to place orders N bps behind BBO (default: 0 = at BBO). Falls back to `mid ± spread_bps` when BBO is unavailable. -**Per-coin overrides** (`--coin-offset-overrides`, `--coin-spread-overrides`, `--coin-size-overrides`): Override BBO offset, spread, or order size per coin. Format: `"SP500:0.5,MSFT:3"`. Supports both bare names and DEX-prefixed names (`xyz:SP500:0.5`). Unspecified coins use the global default. Use `--coin-size-overrides` to set per-coin order size in USD (e.g., `"TSLA:150,NVDA:150"`); setting a coin to `0` skips orders for that coin. +**Per-coin overrides** (`--coin-offset-overrides`, `--coin-spread-overrides`, `--coin-size-overrides`, `--coin-unrealized-loss-overrides`): Override BBO offset, spread, order size, or the unrealized-loss early-close threshold per coin. Format: `"SP500:0.5,MSFT:3"`. Supports both bare names and DEX-prefixed names (`xyz:SP500:0.5`). Unspecified coins use the global default. Use `--coin-size-overrides` to set per-coin order size in USD (e.g., `"TSLA:150,NVDA:150"`); setting a coin to `0` skips orders for that coin. Use `--coin-unrealized-loss-overrides` to relax the threshold on low-vol coins (`"INTC:25"`) or tighten it on volatile ones (`"OIL:10"`); setting a coin to `0` disables the unrealized-loss feature for that coin. **Quiet hours** (`--quiet-hours-utc`): Stop or widen quoting during specific UTC hours (e.g., `"17"` or `"17,18"`). Default: stop quoting entirely. With `--quiet-hours-spread-multiplier N`, widens spread by Nx instead. Positions are still managed during quiet hours. @@ -563,6 +563,7 @@ strategies: coin_offset_overrides: "" # --coin-offset-overrides (per-coin BBO offset: "SP500:0.5,MSFT:3") coin_spread_overrides: "" # --coin-spread-overrides (per-coin spread: "SP500:8,XYZ100:15") coin_size_overrides: "" # --coin-size-overrides (per-coin order size USD: "TSLA:150,NVDA:150") + coin_unrealized_loss_overrides: "" # --coin-unrealized-loss-overrides (per-coin unrealized-loss bps: "INTC:25,OIL:10"; 0 disables) dynamic_offset_enabled: false # --dynamic-offset (auto-adjust offset from adverse selection tracker) dynamic_offset_sensitivity: 0.5 # --dynamic-offset-sensitivity (offset widening per 1bps adverse) dynamic_offset_tighten_rate: 0.25 # --dynamic-offset-tighten-rate (offset tightening for favorable fills) diff --git a/bot.py b/bot.py index 3f38b5e..243fe89 100644 --- a/bot.py +++ b/bot.py @@ -1054,6 +1054,10 @@ def stop(self): help='Per-coin spread overrides in bps (e.g. "SP500:8,XYZ100:15")') parser.add_argument('--coin-size-overrides', type=str, default='', help='Per-coin order size overrides in USD (e.g. "TSLA:150,NVDA:150")') + parser.add_argument('--coin-unrealized-loss-overrides', type=str, default='', + help='Per-coin unrealized-loss early-close threshold in bps ' + '(e.g. "INTC:25,OIL:10"). Falls back to --unrealized-loss-close-bps. ' + 'Setting an override to 0 disables the feature for that coin.') parser.add_argument('--close-refresh-threshold-bps', type=float, help='Refresh close orders when BBO changes by this many bps; 0 to disable (default: 0)') parser.add_argument('--spread-schedule', type=str, default='', @@ -1223,6 +1227,7 @@ def stop(self): 'coin_offset_overrides', 'coin_spread_overrides', 'coin_size_overrides', + 'coin_unrealized_loss_overrides', 'close_refresh_threshold_bps', 'spread_schedule', 'quiet_hours_utc', diff --git a/strategies/market_making_strategy.py b/strategies/market_making_strategy.py index 6f2448c..6cad8e3 100644 --- a/strategies/market_making_strategy.py +++ b/strategies/market_making_strategy.py @@ -129,12 +129,17 @@ def __init__(self, market_data_manager, order_manager, config: Dict) -> None: self._coin_offset_overrides: Dict[str, float] = self.cfg.per_coin.offset self._coin_spread_overrides: Dict[str, float] = self.cfg.per_coin.spread self._coin_size_overrides: Dict[str, float] = self.cfg.per_coin.size + self._coin_unrealized_loss_overrides: Dict[str, float] = self.cfg.per_coin.unrealized_loss if self._coin_offset_overrides: logger.info(f"[mm] Per-coin offset overrides: {self._coin_offset_overrides}") if self._coin_spread_overrides: logger.info(f"[mm] Per-coin spread overrides: {self._coin_spread_overrides}") if self._coin_size_overrides: logger.info(f"[mm] Per-coin size overrides: {self._coin_size_overrides}") + if self._coin_unrealized_loss_overrides: + logger.info( + f"[mm] Per-coin unrealized-loss overrides: {self._coin_unrealized_loss_overrides}" + ) # ---- Micro-price asymmetric offset (aliases of self.cfg.microprice) ---- # self._microprice_enabled: bool = self.cfg.microprice.enabled @@ -207,6 +212,7 @@ def __init__(self, market_data_manager, order_manager, config: Dict) -> None: close_breakeven_pct=self.cfg.close.breakeven_pct, close_aggressive_pct=self.cfg.close.aggressive_pct, unrealized_loss_close_bps=self.cfg.close.unrealized_loss_close_bps, + coin_unrealized_loss_overrides=self._coin_unrealized_loss_overrides, ) @property diff --git a/strategies/mm_config.py b/strategies/mm_config.py index 90b7b8e..ac28fda 100644 --- a/strategies/mm_config.py +++ b/strategies/mm_config.py @@ -173,11 +173,13 @@ class VelocityGuardConfig: @dataclass class PerCoinOverrides: - """Per-coin overrides for offset, spread, and order size.""" + """Per-coin overrides for offset, spread, order size, and unrealized-loss + early-close threshold.""" offset: Dict[str, float] = field(default_factory=dict) spread: Dict[str, float] = field(default_factory=dict) size: Dict[str, float] = field(default_factory=dict) + unrealized_loss: Dict[str, float] = field(default_factory=dict) @dataclass @@ -347,6 +349,7 @@ def from_legacy_dict(cls, d: Dict) -> "MMConfig": offset=parse_coin_overrides(d.get('coin_offset_overrides', '')), spread=parse_coin_overrides(d.get('coin_spread_overrides', '')), size=parse_coin_overrides(d.get('coin_size_overrides', '')), + unrealized_loss=parse_coin_overrides(d.get('coin_unrealized_loss_overrides', '')), ), imbalance=ImbalanceConfig( placement_threshold=float(d.get('imbalance_threshold', 0.0)), diff --git a/strategies/mm_position_closer.py b/strategies/mm_position_closer.py index 1bb0591..521868f 100644 --- a/strategies/mm_position_closer.py +++ b/strategies/mm_position_closer.py @@ -57,6 +57,7 @@ def __init__( close_breakeven_pct: float = 0.50, close_aggressive_pct: float = 0.75, unrealized_loss_close_bps: float = 0.0, + coin_unrealized_loss_overrides: Optional[Dict[str, float]] = None, ) -> None: self.order_manager = order_manager self.market_data = market_data @@ -76,6 +77,11 @@ def __init__( self.close_aggressive_pct = close_aggressive_pct # Unrealized loss early close (0 = disabled) self.unrealized_loss_close_bps = unrealized_loss_close_bps + # Per-coin overrides for unrealized_loss_close_bps. Empty dict = no + # overrides (every coin uses the global threshold). Lookup falls back + # to bare coin name (e.g. "NVDA") if the DEX-prefixed key + # ("xyz:NVDA") is not found. + self._coin_unrealized_loss_overrides: Dict[str, float] = coin_unrealized_loss_overrides or {} # coin -> (entry_time, close_oid or None, close_tier) self._open_positions: Dict[str, Tuple[float, Optional[int], int]] = {} @@ -262,8 +268,10 @@ def manage(self, coin: str, position: Dict, close_position_fn, # on_position_closed paths that don't otherwise know it. self._last_effective_max_age[coin] = effective_max_age - # Unrealized loss early close: taker close when loss exceeds threshold - if self.unrealized_loss_close_bps > 0 and entry_price > 0: + # Unrealized loss early close: taker close when loss exceeds threshold. + # Uses the per-coin override if present, else the global threshold. + unrealized_loss_threshold = self._get_unrealized_loss_bps_for_coin(coin) + if unrealized_loss_threshold > 0 and entry_price > 0: md = self.market_data.get_market_data(coin) if md and md.mid_price > 0: if size > 0: # long @@ -271,7 +279,7 @@ def manage(self, coin: str, position: Dict, close_position_fn, else: # short unrealized_bps = (md.mid_price - entry_price) / entry_price * 10_000 - if unrealized_bps >= self.unrealized_loss_close_bps: + if unrealized_bps >= unrealized_loss_threshold: # Cancel existing close order if close_oid is not None: try: @@ -285,7 +293,7 @@ def manage(self, coin: str, position: Dict, close_position_fn, logger.warning( f"[mm] Position {coin} unrealized loss {unrealized_bps:.1f}bps " - f"exceeds threshold {self.unrealized_loss_close_bps}bps -- " + f"exceeds threshold {unrealized_loss_threshold}bps -- " f"early taker close (age={age:.0f}s)" ) self._record_close(coin, CLOSE_REASON_UNREALIZED_LOSS, age, current_tier, @@ -498,6 +506,21 @@ def _get_spread_for_coin(self, coin: str) -> float: return self._coin_spread_overrides[bare] return self.spread_bps + def _get_unrealized_loss_bps_for_coin(self, coin: str) -> float: + """Return the unrealized-loss early-close threshold (bps) for a coin. + + Falls back to the global ``unrealized_loss_close_bps`` when no + override is set. Same DEX-prefix-or-bare lookup as + ``_get_spread_for_coin``. Returning 0 from an override disables + the feature for that coin (the caller gates on ``> 0``). + """ + if coin in self._coin_unrealized_loss_overrides: + return self._coin_unrealized_loss_overrides[coin] + _, bare = parse_coin(coin) + if bare in self._coin_unrealized_loss_overrides: + return self._coin_unrealized_loss_overrides[bare] + return self.unrealized_loss_close_bps + def _get_tier(self, age: float, max_age: Optional[float] = None) -> int: """Return the close price tier for the given position age.""" effective_max_age = max_age if max_age is not None else self.max_position_age_seconds diff --git a/tests/test_mm_config.py b/tests/test_mm_config.py index b1dceca..457993f 100644 --- a/tests/test_mm_config.py +++ b/tests/test_mm_config.py @@ -95,13 +95,16 @@ def test_defaults_are_empty_dicts(self) -> None: assert cfg.offset == {} assert cfg.spread == {} assert cfg.size == {} + assert cfg.unrealized_loss == {} def test_independent_dicts(self) -> None: # Default factory must produce a fresh dict per instance a = PerCoinOverrides() b = PerCoinOverrides() a.offset['BTC'] = 1.0 + a.unrealized_loss['BTC'] = 25.0 assert 'BTC' not in b.offset + assert 'BTC' not in b.unrealized_loss class TestParseQuietHours: @@ -250,6 +253,7 @@ def test_full_dict_populates_all_groups(self) -> None: 'coin_offset_overrides': 'SP500:0.5,MSFT:3', 'coin_spread_overrides': 'TSLA:2', 'coin_size_overrides': 'NVDA:150', + 'coin_unrealized_loss_overrides': 'INTC:25,OIL:10', 'imbalance_threshold': 0.5, 'imbalance_guard_threshold': 0.4, 'imbalance_guard_depth': 7, @@ -292,6 +296,7 @@ def test_full_dict_populates_all_groups(self) -> None: assert cfg.per_coin.offset == {'SP500': 0.5, 'MSFT': 3.0} assert cfg.per_coin.spread == {'TSLA': 2.0} assert cfg.per_coin.size == {'NVDA': 150.0} + assert cfg.per_coin.unrealized_loss == {'INTC': 25.0, 'OIL': 10.0} assert cfg.imbalance.placement_threshold == 0.5 assert cfg.imbalance.reactive_threshold == 0.4 assert cfg.imbalance.reactive_depth == 7 diff --git a/tests/test_unrealized_loss_close.py b/tests/test_unrealized_loss_close.py index b631b5b..e8e4e8a 100644 --- a/tests/test_unrealized_loss_close.py +++ b/tests/test_unrealized_loss_close.py @@ -22,6 +22,7 @@ def _make_closer( taker_fallback: float = None, spread_bps: float = 10, unrealized_loss_close_bps: float = 0.0, + coin_unrealized_loss_overrides: dict = None, ) -> tuple: """Build a PositionCloser with mocked dependencies.""" om = MagicMock() @@ -37,6 +38,7 @@ def _make_closer( maker_only=maker_only, taker_fallback_age_seconds=taker_fallback, unrealized_loss_close_bps=unrealized_loss_close_bps, + coin_unrealized_loss_overrides=coin_unrealized_loss_overrides, ) om.get_all_positions.return_value = [{'coin': 'BTC', 'szi': '1.0'}] return closer, om, md @@ -271,3 +273,141 @@ def test_cancel_then_taker_close(self): close_fn.assert_called_once_with('BTC') assert 'BTC' not in closer._open_positions assert closer.close_stats[CLOSE_REASON_UNREALIZED_LOSS] == 1 + + +class TestCoinUnrealizedLossOverrides: + """Per-coin overrides relax (raise) or tighten (lower) the threshold.""" + + @staticmethod + def _setup_position(closer, om, md, mid_price: float, coin: str = 'BTC', + size: float = 1.0, entry_price: float = 100.0): + """Register a position and prime market data for one manage() call.""" + market_data_mock = MagicMock() + market_data_mock.mid_price = mid_price + market_data_mock.bid = mid_price - 0.01 + market_data_mock.ask = mid_price + 0.01 + md.get_market_data.return_value = market_data_mock + om.get_all_positions.return_value = [{'coin': coin, 'szi': str(size)}] + entry_time = time.monotonic() - 10 + closer._open_positions[coin] = (entry_time, None, _TIER_NORMAL) + return {'size': size, 'entry_price': entry_price} + + def test_override_relaxes_threshold_blocks_close(self): + """Override 25bps on BTC: a 20bps loss must NOT trigger early close + even though the global threshold (15bps) would have.""" + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'BTC': 25.0}, + ) + # Long: entry=100, mid=99.80 → 20 bps loss (above global 15, below override 25) + position = self._setup_position(closer, om, md, mid_price=99.80) + # Provide a place_take_profit success so manage() can complete the cycle + mock_order = MagicMock() + mock_order.id = 42 + om.create_limit_order.return_value = mock_order + + close_fn = MagicMock() + closer.manage('BTC', position, close_fn) + + close_fn.assert_not_called() + assert closer.close_stats.get(CLOSE_REASON_UNREALIZED_LOSS, 0) == 0 + assert 'BTC' in closer._open_positions + + def test_override_relaxes_threshold_still_fires_above_override(self): + """Override 25bps on BTC: a 26bps loss DOES trigger close.""" + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'BTC': 25.0}, + ) + position = self._setup_position(closer, om, md, mid_price=99.74) # 26 bps + close_fn = MagicMock() + + closer.manage('BTC', position, close_fn) + + close_fn.assert_called_once_with('BTC') + assert closer.close_stats[CLOSE_REASON_UNREALIZED_LOSS] == 1 + + def test_override_tightens_threshold_fires_earlier(self): + """Override 10bps on BTC: a 12bps loss triggers (global 15 would not).""" + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'BTC': 10.0}, + ) + position = self._setup_position(closer, om, md, mid_price=99.88) # 12 bps + close_fn = MagicMock() + + closer.manage('BTC', position, close_fn) + + close_fn.assert_called_once_with('BTC') + assert closer.close_stats[CLOSE_REASON_UNREALIZED_LOSS] == 1 + + def test_unspecified_coin_uses_global_threshold(self): + """Override on BTC only: ETH still uses the global threshold.""" + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'BTC': 25.0}, + ) + # ETH at 16 bps loss (above global 15) → fires under global rule. + position = self._setup_position(closer, om, md, mid_price=99.84, coin='ETH') + close_fn = MagicMock() + + closer.manage('ETH', position, close_fn) + + close_fn.assert_called_once_with('ETH') + assert closer.close_stats[CLOSE_REASON_UNREALIZED_LOSS] == 1 + + def test_dex_prefixed_lookup_falls_back_to_bare(self): + """Override registered as bare 'NVDA' should also match 'xyz:NVDA'.""" + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'NVDA': 25.0}, + ) + # 20 bps loss → would fire under global 15, must NOT fire under + # the bare-name override resolved from xyz:NVDA. + position = self._setup_position(closer, om, md, mid_price=99.80, coin='xyz:NVDA') + mock_order = MagicMock() + mock_order.id = 42 + om.create_limit_order.return_value = mock_order + + close_fn = MagicMock() + closer.manage('xyz:NVDA', position, close_fn) + + close_fn.assert_not_called() + + def test_override_zero_disables_unrealized_loss_for_coin(self): + """Setting an override to 0 disables the feature for that coin even if + the global threshold is non-zero.""" + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'BTC': 0.0}, + ) + # Way past global 15 bps, but BTC override is 0 → disabled. + position = self._setup_position(closer, om, md, mid_price=99.50) # 50 bps + mock_order = MagicMock() + mock_order.id = 42 + om.create_limit_order.return_value = mock_order + + close_fn = MagicMock() + closer.manage('BTC', position, close_fn) + + close_fn.assert_not_called() + assert closer.close_stats.get(CLOSE_REASON_UNREALIZED_LOSS, 0) == 0 + + def test_log_message_reports_effective_threshold(self): + """The warning log shows the per-coin threshold actually applied.""" + from unittest.mock import patch + closer, om, md = _make_closer( + unrealized_loss_close_bps=15, + coin_unrealized_loss_overrides={'BTC': 10.0}, + ) + position = self._setup_position(closer, om, md, mid_price=99.85) # 15 bps + close_fn = MagicMock() + + with patch('strategies.mm_position_closer.logger') as mock_logger: + closer.manage('BTC', position, close_fn) + warns = [str(c) for c in mock_logger.warning.call_args_list] + unrl_lines = [w for w in warns if 'unrealized loss' in w] + assert len(unrl_lines) == 1 + # Must mention the effective threshold (10bps) rather than the + # global (15bps). + assert 'threshold 10' in unrl_lines[0]