diff --git a/README.md b/README.md index 0b7cab1..65ed38a 100644 --- a/README.md +++ b/README.md @@ -356,7 +356,13 @@ 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. -**Per-coin position cap** (`max_position_multiple`, env-only): Suppresses same-direction entries once the accumulated position value (`|position| × mid_price`) reaches `max_position_multiple × effective_order_size_usd` for that coin. Opposite-side entries still place so existing inventory can unwind through normal quoting. Prevents the accumulated-position-then-single-large-close scenario that exposes a market-making bot to oversized adverse fills. The cap respects `coin_size_overrides`, so a per-coin order-size override tightens or loosens the cap proportionally. Default `0.0` (disabled) preserves the pre-cap behaviour. +**Per-coin position cap** (`max_position_multiple`, env-only): Suppresses same-direction entries once the accumulated position value (`|position| × mid_price`) reaches `max_position_multiple × effective_order_size_usd` for that coin, to prevent the accumulated-position-then-single-large-close scenario that exposes a market-making bot to oversized adverse fills. The cap respects `coin_size_overrides`, so a per-coin order-size override tightens or loosens the cap proportionally. Default `0.0` (disabled) preserves the pre-cap behaviour. **⚠️ No-op in the current single-sided flow** — see [Inventory skew and position cap are inert](#inventory-skew-and-position-cap-are-inert) below. + +#### Inventory skew and position cap are inert + +`inventory_skew_bps` and `max_position_multiple` are **currently no-ops**: the market-making loop hands any coin that holds a position to the position closer and skips quoting for it, so order placement only ever runs while flat. Both features gate on a non-zero position, so neither can influence a placed order. Setting either to a non-zero value logs a warning at startup. + +They are retained (not removed) because both become meaningful under a two-sided quoting mode, where a coin keeps quoting while holding inventory — `inventory_skew_bps` would then shift quotes to mean-revert inventory, and `max_position_multiple` would bound the accumulation. Implementing that mode is a deliberate strategy change, not a config toggle: it trades faster inventory flattening (which keeps post-fill markout mild) for more quote uptime, and it only pays off when the risk manager's net-inventory cap is large relative to the order size. **Fill feature logging** (`--fill-feature-log`): Writes one JSON line per fill to a daily-rotated file (`{dir}/YYYYMMDD.jsonl`, UTC) containing the fill's join keys (`tid`/`oid`/`hash`), fill attributes, order-book features at fill time (spread, book imbalance, micro-price skew, top-of-book sizes, recent realized volatility), and the tracker's 5s/30s/60s markout samples as labels — a ready-made supervised dataset for offline adverse-selection modelling. Observation-only: the WebSocket thread performs no file IO (records are buffered in memory and flushed from the main loop after a 65s maturity window so markout labels can be embedded), all failures are swallowed, and a daily size cap plus buffer cap protect disk and memory. Requires `--enable-ws` and `--enable-adverse-selection-log`. Feature definitions live in a single shared function (`fill_features.compute_fill_features`) so future in-bot inference uses identical inputs (no train/serve skew). Default disabled. @@ -611,7 +617,7 @@ strategies: unrealized_loss_close_bps: 0 # --unrealized-loss-close-bps (early taker close when unrealized loss exceeds this bps; 0 = disabled) bbo_mode: false # --bbo-mode (place orders at best bid/ask instead of mid ± spread) bbo_offset_bps: 0 # --bbo-offset-bps (bps behind BBO; 0 = at BBO) - inventory_skew_bps: 0 # --inventory-skew-bps (skew per unit of inventory; 0 = disabled) + inventory_skew_bps: 0 # --inventory-skew-bps (NO-OP in the current single-sided flow: a coin holding a position does not re-quote, so the skew never reaches an order; warns at startup if non-zero) 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") @@ -655,7 +661,7 @@ strategies: 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) - max_position_multiple: 0.0 # env-only (per-coin entry-side cap on accumulated |position| × mid as a multiple of order_size_usd; 0 disables) + max_position_multiple: 0.0 # env-only (NO-OP in the current single-sided flow: entries are not placed while a position is open, so the same-side suppression never triggers; warns at startup if non-zero) account_cap_pct: 0.05 # --account-cap-pct max_positions: 3 take_profit_percent: 1 diff --git a/strategies/market_making_strategy.py b/strategies/market_making_strategy.py index 8a38ba2..c42f874 100644 --- a/strategies/market_making_strategy.py +++ b/strategies/market_making_strategy.py @@ -140,11 +140,8 @@ def __init__(self, market_data_manager, order_manager, config: Dict) -> None: # suppress same-direction entries to prevent oversized force-close events. # 0.0 = disabled (legacy behaviour). self._max_position_multiple: float = self.cfg.position_cap.max_position_multiple - if self._max_position_multiple > 0: - logger.info( - f"[mm] Position cap armed: max_position_multiple=" - f"{self._max_position_multiple}x order_size_usd" - ) + + self._warn_inert_parameters() # ---- Forager: composite per-coin health scoring ---- # self._coin_health_tracker: Optional[CoinHealthTracker] = ( @@ -574,13 +571,11 @@ def run(self, coins: List[str]) -> None: pos = self.positions.get(coin) if pos and pos['size'] != 0: active_positions += 1 - md = self.market_data.get_market_data(coin) - mid = md.mid_price if md else 0 - skew = self._calculate_inventory_skew(coin, mid) - if abs(skew) > 0: - coin_statuses.append(f"{coin}:skew{skew:+.1f}bp") - else: - coin_statuses.append(f"{coin}:pos") + # Deliberately does not surface the inventory skew: a coin holding + # a position never re-quotes (it is delegated to PositionCloser), so + # printing a non-zero skew here would imply an effect on orders that + # cannot happen. See _calculate_inventory_skew for the full note. + coin_statuses.append(f"{coin}:pos") else: # Show vol-adjusted offset when it differs from base (read-only) if self.vol_adjust_enabled and self.bbo_mode: @@ -914,11 +909,16 @@ def _place_orders( logger.debug(f"[mm] {coin} oracle guard blocking {sorted(blocked)}") # Per-coin position cap: suppress same-direction entries once - # accumulated |position| × mid_price reaches the cap. Opposite-side - # entries are still allowed so existing inventory can unwind through - # normal quoting. ``self._max_position_multiple == 0`` disables the - # check entirely (legacy behaviour). ``getattr`` is used so tests - # that bypass ``__init__`` inherit the disabled default. + # accumulated |position| × mid_price reaches the cap. + # ``self._max_position_multiple == 0`` disables the check entirely + # (legacy behaviour). ``getattr`` is used so tests that bypass + # ``__init__`` inherit the disabled default. + # + # NOTE: unreachable in the current single-sided flow. ``run()`` delegates + # any coin holding a position to PositionCloser and ``continue``s, so this + # function only runs while flat and the ``size != 0`` guard below never + # passes in production. Retained for a future two-sided quoting mode; the + # unit tests below exercise it by calling ``_place_orders`` directly. if getattr(self, '_max_position_multiple', 0.0) > 0: pos = self.positions.get(coin) if pos is not None and pos.get('size', 0) != 0: @@ -989,11 +989,47 @@ def _get_spread_prices(self, mid_price: float) -> tuple: offset = mid_price * (self.spread_bps / 10_000) return mid_price - offset, mid_price + offset + def _warn_inert_parameters(self) -> None: + """Warn when a parameter that cannot take effect is configured non-zero. + + ``run()`` hands any coin that holds a position to :class:`PositionCloser` + and ``continue``s, so :meth:`_place_orders` only ever runs while flat. + Both the inventory skew and the entry-side position cap gate on a + *non-zero* position, so neither can influence a placed order in the + current single-sided flow. The parameters are kept (not removed) so a + future two-sided quoting mode can activate them, but an operator setting + one today must not be left assuming it is live. + """ + if self.inventory_skew_bps: + logger.warning( + f"[mm] inventory_skew_bps={self.inventory_skew_bps} has NO EFFECT in the " + f"current single-sided flow: a coin holding a position is managed by " + f"PositionCloser and does not re-quote, so the skew never reaches an order" + ) + if self._max_position_multiple > 0: + logger.warning( + f"[mm] max_position_multiple={self._max_position_multiple} has NO EFFECT in " + f"the current single-sided flow: entries are not placed while a position is " + f"open, so the same-side suppression never triggers" + ) + def _calculate_inventory_skew(self, coin: str, mid_price: float) -> float: """Calculate price skew in bps based on current inventory. Positive skew shifts both prices down (encourages selling when long). Negative skew shifts both prices up (encourages buying when short). + + .. note:: + **No-op in the current single-sided flow.** :meth:`run` delegates any + coin that holds a position to :class:`PositionCloser` and ``continue``s, + so the only production caller (:meth:`_place_orders`) always runs while + flat and the position guard below returns ``0.0``. ``self.positions`` is + only repopulated by ``update_positions()`` at the top of a cycle, so + there is no race that could make it non-zero mid-cycle either. + + Kept (rather than removed) for a future two-sided quoting mode, where a + coin would keep quoting while holding inventory. Until then, setting + ``inventory_skew_bps`` has no effect and ``__init__`` warns about it. """ if not self.inventory_skew_bps: return 0.0 diff --git a/tests/test_mm_cycle_log.py b/tests/test_mm_cycle_log.py index 8a11f0b..84f8b64 100644 --- a/tests/test_mm_cycle_log.py +++ b/tests/test_mm_cycle_log.py @@ -1,9 +1,15 @@ -"""Tests for MM strategy [cycle] log with inventory skew info.""" +"""Tests for the MM strategy ``[cycle]`` log. + +Note: the cycle log deliberately does *not* surface the inventory skew -- +see ``tests/test_mm_single_sided_flow.py`` for why it would be misleading. +""" import logging from collections import defaultdict from unittest.mock import MagicMock, patch +import pytest + from strategies.market_making_strategy import MarketMakingStrategy @@ -90,8 +96,15 @@ def test_idle_coin(self, caplog): assert len(cycle_lines) == 1 assert 'BTC:idle' in cycle_lines[0].message - def test_position_with_skew(self, caplog): - s, om, md = _make_strategy(inventory_skew_bps=2, order_size_usd=100) + @pytest.mark.parametrize('inventory_skew_bps', [0, 2]) + def test_position_never_reports_skew(self, inventory_skew_bps, caplog): + """A held position logs ``:pos`` regardless of the configured skew. + + The skew cannot reach an order in the single-sided flow (the coin is + delegated to PositionCloser and stops quoting), so surfacing it here + would imply an effect that does not exist. + """ + s, om, md = _make_strategy(inventory_skew_bps=inventory_skew_bps, order_size_usd=100) s.positions = {'BTC': {'size': 1.0, 'entry_price': 100.0, 'unrealized_pnl': 0, 'margin_used': 10}} s.update_positions = MagicMock() # prevent positions reset @@ -102,35 +115,29 @@ def test_position_with_skew(self, caplog): cycle_lines = [r for r in caplog.records if '[cycle]' in r.message] assert len(cycle_lines) == 1 - assert 'BTC:skew+' in cycle_lines[0].message + assert 'BTC:pos' in cycle_lines[0].message + assert 'skew' not in cycle_lines[0].message assert '1 pos' in cycle_lines[0].message - def test_position_with_zero_skew(self, caplog): - s, om, md = _make_strategy(inventory_skew_bps=0) - s.positions = {'BTC': {'size': 1.0, 'entry_price': 100.0, - 'unrealized_pnl': 0, 'margin_used': 10}} - s.update_positions = MagicMock() - md.get_market_data.return_value = MagicMock(mid_price=100.0, bid=0, ask=0) - - with caplog.at_level(logging.INFO): - s.run(['BTC']) - - cycle_lines = [r for r in caplog.records if '[cycle]' in r.message] - assert 'BTC:pos' in cycle_lines[0].message + def test_idle_coin_without_market_data(self, caplog): + """A flat coin still renders when market data is unavailable. - def test_market_data_none(self, caplog): - s, om, md = _make_strategy(inventory_skew_bps=2) - s.positions = {'BTC': {'size': 1.0, 'entry_price': 100.0, - 'unrealized_pnl': 0, 'margin_used': 10}} - s.update_positions = MagicMock() + The position branch no longer reads market data, so the remaining + market-data-dependent path in this log is the idle branch. + """ + s, om, md = _make_strategy() md.get_market_data.return_value = None with caplog.at_level(logging.INFO): s.run(['BTC']) + # Proves the None actually flowed through the quoting path rather than + # the assertion passing on an unexercised branch. + assert md.get_market_data.called cycle_lines = [r for r in caplog.records if '[cycle]' in r.message] - # skew=0 when no market data → shows :pos - assert 'BTC:pos' in cycle_lines[0].message + assert len(cycle_lines) == 1 + assert 'BTC:idle' in cycle_lines[0].message + assert '0 pos' in cycle_lines[0].message def test_truncation(self, caplog): s, om, md = _make_strategy() diff --git a/tests/test_mm_inventory_skew.py b/tests/test_mm_inventory_skew.py index c177fc3..cc42cc1 100644 --- a/tests/test_mm_inventory_skew.py +++ b/tests/test_mm_inventory_skew.py @@ -1,4 +1,12 @@ -"""Tests for inventory-based spread skewing.""" +"""Tests for inventory-based spread skewing. + +Note: these tests exercise ``_calculate_inventory_skew`` / ``_place_orders`` +directly with a non-zero position, a state the live flow never produces -- +``run()`` delegates coins holding a position to PositionCloser, so the skew is +inert in production. They pin the arithmetic for a future two-sided quoting +mode; see ``tests/test_mm_single_sided_flow.py`` for the invariant that makes +it inert. +""" from collections import defaultdict from unittest.mock import MagicMock, patch diff --git a/tests/test_mm_position_cap.py b/tests/test_mm_position_cap.py index d313d96..74808b5 100644 --- a/tests/test_mm_position_cap.py +++ b/tests/test_mm_position_cap.py @@ -2,9 +2,13 @@ The cap suppresses same-direction entries once the accumulated position value (``|size| × mid_price``) reaches -``max_position_multiple × effective_order_size_usd``. Opposite-side -entries are still placed so existing inventory can unwind through -normal quoting. +``max_position_multiple × effective_order_size_usd``. + +Note: these tests call ``_place_orders`` directly with a non-zero position, +a state the live flow never produces -- ``run()`` delegates coins holding a +position to PositionCloser, so the cap is inert in production. They pin the +arithmetic for a future two-sided quoting mode; see +``tests/test_mm_single_sided_flow.py`` for the invariant that makes it inert. """ from collections import defaultdict diff --git a/tests/test_mm_single_sided_flow.py b/tests/test_mm_single_sided_flow.py new file mode 100644 index 0000000..758fa6e --- /dev/null +++ b/tests/test_mm_single_sided_flow.py @@ -0,0 +1,188 @@ +"""Tests pinning the single-sided quoting invariant. + +``MarketMakingStrategy.run()`` hands any coin that holds a position to +``PositionCloser`` and ``continue``s, so ``_place_orders()`` only ever runs +while flat. Two documented parameters -- ``inventory_skew_bps`` and +``max_position_multiple`` -- gate on a *non-zero* position and are therefore +inert in production, which is why ``__init__`` warns when either is set. + +The tests below pin that invariant. If a future change introduces two-sided +quoting (a coin keeps quoting while holding inventory), ``test_place_orders_*`` +will fail -- that is intentional: the startup warnings, the README note and the +``_calculate_inventory_skew`` docstring must be updated in the same change. +""" + +from collections import defaultdict +from unittest.mock import MagicMock, patch + +import pytest + +from strategies.market_making_strategy import MarketMakingStrategy + + +def _make_strategy(): + """MM strategy with the minimal attribute set needed to drive ``run()``. + + Bypasses ``__init__`` so the test stays fast and does not touch the SDK / + config layering chain. + """ + with patch.object(MarketMakingStrategy, '__init__', lambda self, *a, **k: None): + s = MarketMakingStrategy.__new__(MarketMakingStrategy) + + s.spread_bps = 10 + s.order_size_usd = 100.0 + s.max_open_orders = 4 + s.max_positions = 10 + s.maker_only = True + s.bbo_mode = True + s.bbo_offset_bps = 1.0 + s.close_immediately = False + s.inventory_skew_bps = 0 + s.inventory_skew_cap = 3.0 + s.imbalance_threshold = 0.0 + s.loss_streak_limit = 0 + s.loss_streak_cooldown = 300 + s.refresh_tolerance_bp = 0 + s._loss_streaks = defaultdict(int) + s._coin_cooldown_until = {} + s._quiet_hours = set() + s._quiet_spread_multiplier = 0.0 + s._spread_schedule = {} + s._coin_offset_overrides = {} + s._coin_spread_overrides = {} + s._coin_size_overrides = {} + s._dynamic_offset_enabled = False + s._adverse_tracker = None + s._coin_health_tracker = None + s._was_quiet = False + s._drain_flag_file = '' + s._was_drain = False + s.vol_adjust_enabled = False + s._microprice_enabled = False + s._recent_mids = {} + s.positions = {} + s._orders_placed = 0 + s._orders_placed_per_coin = defaultdict(int) + s._fills_detected = 0 + s._fills_per_coin = defaultdict(int) + s._fill_rate_log_interval = 300 + s._last_fill_rate_log = 0.0 + s._prev_position_coins = set() + s._prev_positions = {} + s._max_position_multiple = 0.0 + + s.order_manager = MagicMock() + md = MagicMock() + md.get_sz_decimals.return_value = 0 + md.price_rounding_params.return_value = (0, True) + s.market_data = md + + tracker = MagicMock() + tracker.get_order_count.return_value = 0 + tracker.active_coins.return_value = 0 + s._tracker = tracker + s._closer = MagicMock() + s._closer.tracked_coins = set() + s._rejection_tracker = MagicMock() + + # Isolate the branch under test: everything the loop calls around the + # position check is stubbed so only the routing decision is exercised. + s.update_positions = MagicMock() + s._log_fill_rate = MagicMock() + s._log_dynamic_age = MagicMock() + s._get_dynamic_position_age = MagicMock(return_value=None) + s._compute_ideal_prices = MagicMock(return_value=None) + s._place_orders = MagicMock() + return s + + +class TestSingleSidedFlowInvariant: + """``_place_orders`` must never run for a coin that holds a position.""" + + def test_place_orders_skipped_while_position_open(self): + s = _make_strategy() + s.positions = {'BTC': {'size': 1.0, 'entryPx': 100.0}} + + s.run(['BTC']) + + s._place_orders.assert_not_called() + s._closer.manage.assert_called_once() + + def test_place_orders_runs_when_flat(self): + s = _make_strategy() + s.positions = {} + + s.run(['BTC']) + + s._place_orders.assert_called_once() + s._closer.manage.assert_not_called() + + def test_only_flat_coins_are_quoted_in_a_mixed_cycle(self): + """With one coin holding and one flat, only the flat coin quotes.""" + s = _make_strategy() + s.positions = {'BTC': {'size': 1.0, 'entryPx': 100.0}} + + s.run(['BTC', 'ETH']) + + quoted = [c.args[0] for c in s._place_orders.call_args_list] + assert quoted == ['ETH'] + + +class TestNoOpDisclosureWarnings: + """Non-zero inert parameters must announce themselves at startup.""" + + def _init_with(self, inventory_skew_bps: float, max_position_multiple: float, caplog): + """Run only the disclosure block against a stub instance. + + ``__init__`` builds the whole config chain, so the block is exercised + directly with the same inputs it reads. + """ + s = _make_strategy() + s.inventory_skew_bps = inventory_skew_bps + s._max_position_multiple = max_position_multiple + with caplog.at_level('WARNING'): + MarketMakingStrategy._warn_inert_parameters(s) + return caplog.text + + def test_warns_when_inventory_skew_set(self, caplog): + text = self._init_with(2.0, 0.0, caplog) + assert 'inventory_skew_bps' in text + assert 'NO EFFECT' in text + + def test_warns_when_position_cap_set(self, caplog): + text = self._init_with(0, 2.5, caplog) + assert 'max_position_multiple' in text + assert 'NO EFFECT' in text + + def test_silent_at_defaults(self, caplog): + text = self._init_with(0, 0.0, caplog) + assert 'NO EFFECT' not in text + + +class TestCycleLogOmitsSkew: + """The cycle log must not print a skew that cannot reach an order.""" + + def test_position_coin_logs_pos_not_skew(self, caplog): + s = _make_strategy() + s.inventory_skew_bps = 2.0 # would produce a non-zero skew if surfaced + s.positions = {'BTC': {'size': 1.0, 'entryPx': 100.0}} + + with caplog.at_level('INFO'): + s.run(['BTC']) + + cycle_lines = [r.message for r in caplog.records if '[cycle]' in r.message] + assert cycle_lines, 'expected a [cycle] log line' + assert 'BTC:pos' in cycle_lines[0] + assert 'skew' not in cycle_lines[0] + + +class TestInventorySkewStillZeroWhenFlat: + """The guard that makes the feature inert is itself pinned.""" + + @pytest.mark.parametrize('positions', [{}, {'BTC': {'size': 0.0}}]) + def test_returns_zero_without_position(self, positions): + s = _make_strategy() + s.inventory_skew_bps = 5.0 + s.positions = positions + + assert s._calculate_inventory_skew('BTC', 100.0) == 0.0