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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<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.

**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.

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
70 changes: 53 additions & 17 deletions strategies/market_making_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
53 changes: 30 additions & 23 deletions tests/test_mm_cycle_log.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
10 changes: 9 additions & 1 deletion tests/test_mm_inventory_skew.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 7 additions & 3 deletions tests/test_mm_position_cap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading