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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='',
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions strategies/market_making_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion strategies/mm_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
31 changes: 27 additions & 4 deletions strategies/mm_position_closer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]] = {}
Expand Down Expand Up @@ -262,16 +268,18 @@ 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
unrealized_bps = (entry_price - md.mid_price) / entry_price * 10_000
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:
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions tests/test_mm_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions tests/test_unrealized_loss_close.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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]