Skip to content

Commit 05135da

Browse files
keitajclaude
andauthored
fix: thread ideal prices through _place_orders to remove double work (#145)
Address two should-fix items from the PR #144 review: 1. ``_calculate_inventory_skew`` was being called twice per cycle in ``_place_orders`` (once inside ``_compute_ideal_prices`` and once for the trailing debug log) and up to three times when refresh tolerance is enabled (the run-loop's tolerance check also invoked ``_compute_ideal_prices``). Now ``_compute_ideal_prices`` returns ``(buy, sell, skew_bps)``; ``_place_orders`` accepts the cached tuple and only logs. 2. The volatility rolling buffer (``_record_mid_price``) was being updated inside ``_place_orders`` *after* the run-loop's tolerance check had already evaluated drift, so the tolerance check used a buffer one sample stale relative to the placed order. The buffer update now lives at the top of the BBO branch in ``_compute_ideal_prices``, and the run loop computes the ideal tuple once and threads it into ``_place_orders``, so both call paths see identical state. The threading is opt-in via the new ``ideal_prices`` keyword on ``_place_orders``; legacy callers that omit it (existing tests, the strategy fallback when prices cannot be determined) compute inline as before. Adds four targeted tests verifying the single-call invariants for both the inventory skew and the volatility buffer. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 13d5e14 commit 05135da

2 files changed

Lines changed: 187 additions & 46 deletions

File tree

strategies/market_making_strategy.py

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,20 @@ def run(self, coins: List[str]) -> None:
411411
# No position — normal MM flow
412412
close_oid = self._closer.get_close_oid(coin)
413413

414+
# Compute ideal prices once per cycle and reuse for both the
415+
# tolerance refresh decision and order placement. This also
416+
# ensures the volatility rolling buffer is updated exactly
417+
# once per cycle (inside ``_compute_ideal_prices``), so the
418+
# tolerance check and the placed order are evaluated against
419+
# the same buffer state.
420+
ideal = self._compute_ideal_prices(coin)
421+
414422
if getattr(self, 'refresh_tolerance_bp', 0) > 0:
415-
ideal = self._compute_ideal_prices(coin)
416423
if ideal is None:
417424
# Fall back to age-only when ideal price is unavailable
418425
self._tracker.cancel_stale_orders(coin, close_oid=close_oid)
419426
else:
420-
ideal_buy, ideal_sell = ideal
427+
ideal_buy, ideal_sell, _ = ideal
421428
self._tracker.refresh_orders_with_tolerance(
422429
coin,
423430
ideal_prices={
@@ -461,7 +468,7 @@ def run(self, coins: List[str]) -> None:
461468
logger.info(f"[mm] {coin} cooldown expired, resuming")
462469

463470
if self._tracker.get_order_count(coin) < self.max_open_orders:
464-
self._place_orders(coin)
471+
self._place_orders(coin, ideal_prices=ideal)
465472

466473
except API_ERRORS as e:
467474
logger.error(f"[mm] Error processing {coin}: {e}")
@@ -644,14 +651,18 @@ def _get_dynamic_position_age(self, coin: str) -> Optional[float]:
644651

645652
return age
646653

647-
def _compute_ideal_prices(self, coin: str) -> Optional[Tuple[float, float]]:
648-
"""Compute current ideal ``(buy_price, sell_price)`` for ``coin``.
654+
def _compute_ideal_prices(self, coin: str) -> Optional[Tuple[float, float, float]]:
655+
"""Compute current ideal ``(buy_price, sell_price, skew_bps)`` for ``coin``.
656+
657+
Mirrors the price computation that was previously inlined in
658+
:meth:`_place_orders`. Updates the volatility rolling buffer via
659+
:meth:`_record_mid_price` so callers can rely on this method as the
660+
single source of truth for both order placement and refresh-tolerance
661+
drift evaluation. Returns ``None`` when the ideal price cannot be
662+
determined (no market data, mid <= 0).
649663
650-
Mirrors the price computation in :meth:`_place_orders` but is a
651-
pure helper (no side effects on rolling buffers, no order placement).
652-
Used by the run loop to evaluate refresh-tolerance drift before
653-
deciding whether to keep existing orders. Returns ``None`` when the
654-
ideal price cannot be determined (no market data, mid <= 0).
664+
The ``skew_bps`` value is returned alongside the prices so callers can
665+
log it without recomputing :meth:`_calculate_inventory_skew`.
655666
"""
656667
market_data = self.market_data.get_market_data(coin)
657668
if not market_data:
@@ -664,6 +675,10 @@ def _compute_ideal_prices(self, coin: str) -> Optional[Tuple[float, float]]:
664675
rp = self.market_data.price_rounding_params(coin)
665676

666677
if self.bbo_mode and market_data.bid > 0 and market_data.ask > 0:
678+
# Update the rolling vol buffer here so the volatility-adjusted
679+
# offset below sees the freshest sample in both call paths
680+
# (run-loop tolerance check and order placement).
681+
self._record_mid_price(coin, mid_price)
667682
base_offset = self._get_coin_offset(coin)
668683
if self.vol_adjust_enabled:
669684
effective_offset_bps = self._get_volatility_adjusted_offset(coin, base_offset)
@@ -708,41 +723,43 @@ def _compute_ideal_prices(self, coin: str) -> Optional[Tuple[float, float]]:
708723
buy_price = round_price(buy_price * (1 - skew_mult), *rp)
709724
sell_price = round_price(sell_price * (1 - skew_mult), *rp)
710725

711-
return buy_price, sell_price
726+
return buy_price, sell_price, skew
712727

713-
def _place_orders(self, coin: str) -> None:
728+
def _place_orders(
729+
self,
730+
coin: str,
731+
ideal_prices: Optional[Tuple[float, float, float]] = None,
732+
) -> None:
714733
"""Place a buy and a sell limit order.
715734
716735
In BBO mode, orders are placed at or near the best bid/ask.
717736
Otherwise, orders are placed symmetrically around mid price at
718737
``spread_bps``. Uses ``bulk_place_orders`` for a single API call.
738+
739+
``ideal_prices`` is the pre-computed ``(buy, sell, skew_bps)`` tuple
740+
produced by :meth:`_compute_ideal_prices` earlier in the same cycle.
741+
When ``None`` (legacy callers / tests that bypass the run loop), the
742+
prices are computed inline. Threading the cached tuple in from the
743+
run loop avoids a redundant compute and double-update of the
744+
volatility buffer.
719745
"""
720746
from order_manager import Order
721747

722748
if coin in self._closer.tracked_coins:
723749
return
724750

725-
market_data = self.market_data.get_market_data(coin)
726-
if not market_data:
727-
logger.warning(f"[mm] No market data for {coin}, skipping")
728-
return
729-
730-
mid_price = market_data.mid_price
731-
if mid_price <= 0:
732-
return
733-
734-
# Volatility buffer is updated here so it occurs once per placement
735-
# cycle even when ``_compute_ideal_prices`` is also called from the
736-
# run loop (which is a pure helper).
737-
if self.bbo_mode and market_data.bid > 0 and market_data.ask > 0:
738-
self._record_mid_price(coin, mid_price)
739-
740-
prices = self._compute_ideal_prices(coin)
741-
if prices is None:
742-
return
743-
buy_price, sell_price = prices
751+
if ideal_prices is None:
752+
prices = self._compute_ideal_prices(coin)
753+
if prices is None:
754+
# Distinguish "no market data" so the warning matches the
755+
# pre-refactor log shape used by tests/operators.
756+
if not self.market_data.get_market_data(coin):
757+
logger.warning(f"[mm] No market data for {coin}, skipping")
758+
return
759+
else:
760+
prices = ideal_prices
744761

745-
skew = self._calculate_inventory_skew(coin, mid_price)
762+
buy_price, sell_price, skew = prices
746763
if skew != 0.0:
747764
logger.debug(f"[mm] Inventory skew {coin}: {skew:.1f}bps")
748765

@@ -771,13 +788,15 @@ def _place_orders(self, coin: str) -> None:
771788
skip_buy = False
772789
skip_sell = False
773790
if self.imbalance_threshold > 0:
774-
imb = market_data.book_imbalance
775-
if imb < -self.imbalance_threshold:
776-
skip_buy = True
777-
logger.debug(f"[mm] {coin} skipping BUY (book imbalance {imb:.2f})")
778-
elif imb > self.imbalance_threshold:
779-
skip_sell = True
780-
logger.debug(f"[mm] {coin} skipping SELL (book imbalance {imb:.2f})")
791+
market_data = self.market_data.get_market_data(coin)
792+
if market_data is not None:
793+
imb = market_data.book_imbalance
794+
if imb < -self.imbalance_threshold:
795+
skip_buy = True
796+
logger.debug(f"[mm] {coin} skipping BUY (book imbalance {imb:.2f})")
797+
elif imb > self.imbalance_threshold:
798+
skip_sell = True
799+
logger.debug(f"[mm] {coin} skipping SELL (book imbalance {imb:.2f})")
781800

782801
if (
783802
current_count < self.max_open_orders

tests/test_refresh_tolerance.py

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,12 @@ def test_spread_mode_symmetric_around_mid(self):
120120

121121
result = s._compute_ideal_prices("BTC")
122122
assert result is not None
123-
buy, sell = result
123+
buy, sell, skew = result
124124
# 10bp around 100.0 = 99.9 / 100.1
125125
assert abs(buy - 99.9) < 1e-6
126126
assert abs(sell - 100.1) < 1e-6
127+
# No position -> no inventory skew.
128+
assert skew == 0.0
127129

128130
def test_bbo_mode_at_best_bid_ask(self):
129131
s, _om, md, _ = _make_strategy()
@@ -136,12 +138,16 @@ def test_bbo_mode_at_best_bid_ask(self):
136138
s._get_coin_offset = lambda coin: 0.0
137139
s._calculate_microprice_offsets = lambda coin, off: (off, off)
138140
s._calculate_inventory_skew = lambda coin, mid: 0.0
141+
# Vol buffer is updated inside _compute_ideal_prices in BBO mode;
142+
# stub it out to keep this test focused on price math.
143+
s._record_mid_price = lambda coin, mid: None
139144

140145
result = s._compute_ideal_prices("BTC")
141146
assert result is not None
142-
buy, sell = result
147+
buy, sell, skew = result
143148
assert abs(buy - 99.95) < 1e-6
144149
assert abs(sell - 100.05) < 1e-6
150+
assert skew == 0.0
145151

146152

147153
class TestRunLoopTolerancePath:
@@ -156,8 +162,8 @@ def test_disabled_uses_legacy_cancel_stale_orders(self):
156162
# Simulate the run-loop dispatch directly (without the surrounding
157163
# boilerplate of MarketMakingStrategy.run): tolerance is 0, so the
158164
# legacy method should be chosen.
165+
ideal = s._compute_ideal_prices("BTC")
159166
if s.refresh_tolerance_bp > 0:
160-
ideal = s._compute_ideal_prices("BTC")
161167
tracker.refresh_orders_with_tolerance(
162168
"BTC",
163169
ideal_prices={"B": ideal[0], "A": ideal[1]},
@@ -173,15 +179,20 @@ def test_disabled_uses_legacy_cancel_stale_orders(self):
173179

174180
def test_enabled_uses_refresh_with_tolerance(self):
175181
"""``refresh_tolerance_bp > 0`` -> ``refresh_orders_with_tolerance`` is invoked."""
182+
from order_manager import OrderSide
183+
176184
s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=2.0)
177185
market_data = _market_data(mid=100.0, bid=99.99, ask=100.01)
178186
md.get_market_data.return_value = market_data
179187

188+
ideal = s._compute_ideal_prices("BTC")
180189
if s.refresh_tolerance_bp > 0:
181-
ideal = s._compute_ideal_prices("BTC")
182190
tracker.refresh_orders_with_tolerance(
183191
"BTC",
184-
ideal_prices={"B": ideal[0], "A": ideal[1]},
192+
ideal_prices={
193+
OrderSide.BUY.value: ideal[0],
194+
OrderSide.SELL.value: ideal[1],
195+
},
185196
tolerance_bp=s.refresh_tolerance_bp,
186197
max_age_seconds=s.refresh_max_age_seconds,
187198
close_oid=None,
@@ -193,11 +204,122 @@ def test_enabled_uses_refresh_with_tolerance(self):
193204
call_kwargs = tracker.refresh_orders_with_tolerance.call_args.kwargs
194205
assert call_kwargs["tolerance_bp"] == 2.0
195206
assert call_kwargs["max_age_seconds"] == 120.0
196-
assert "B" in call_kwargs["ideal_prices"]
197-
assert "A" in call_kwargs["ideal_prices"]
207+
assert OrderSide.BUY.value in call_kwargs["ideal_prices"]
208+
assert OrderSide.SELL.value in call_kwargs["ideal_prices"]
198209
tracker.cancel_stale_orders.assert_not_called()
199210

200211

212+
class TestSingleComputePerCycle:
213+
"""The two should-fix items from PR #144 review:
214+
215+
1. ``_calculate_inventory_skew`` is computed once per cycle.
216+
2. ``_record_mid_price`` (vol buffer update) fires once per cycle, so
217+
the run-loop tolerance check and the actual placement evaluate
218+
against the same buffer state.
219+
"""
220+
221+
def _stub_for_place_orders(self, s):
222+
s._calculate_microprice_offsets = lambda coin, off: (off, off)
223+
s._get_coin_offset = lambda coin: 0.0
224+
s._get_coin_spread = lambda coin: s.spread_bps
225+
s._get_hourly_spread_multiplier = lambda: 1.0
226+
s.calculate_position_size = lambda coin, signal: 1.0
227+
228+
def test_inventory_skew_called_once_when_threading_ideal_prices(self):
229+
"""Threading ``ideal_prices`` into ``_place_orders`` skips the
230+
redundant inventory-skew computation."""
231+
s, _om, md, _tracker = _make_strategy(refresh_tolerance_bp=2.0)
232+
self._stub_for_place_orders(s)
233+
market_data = _market_data(mid=100.0, bid=99.99, ask=100.01)
234+
md.get_market_data.return_value = market_data
235+
md.round_size.return_value = 1.0
236+
s.order_manager.bulk_place_orders.return_value = []
237+
238+
skew_calls = {"n": 0}
239+
240+
def counting_skew(_coin, _mid):
241+
skew_calls["n"] += 1
242+
return 0.0
243+
244+
s._calculate_inventory_skew = counting_skew
245+
s._record_mid_price = lambda coin, mid: None # not BBO mode here
246+
247+
# Cycle simulation: run-loop computes once, then passes to placement.
248+
ideal = s._compute_ideal_prices("BTC")
249+
assert skew_calls["n"] == 1
250+
s._place_orders("BTC", ideal_prices=ideal)
251+
# _place_orders must not recompute the skew.
252+
assert skew_calls["n"] == 1
253+
254+
def test_inventory_skew_recomputed_when_ideal_prices_omitted(self):
255+
"""Legacy callers that omit ``ideal_prices`` still get a working
256+
path that computes prices internally (one skew call total)."""
257+
s, _om, md, _tracker = _make_strategy(refresh_tolerance_bp=0.0)
258+
self._stub_for_place_orders(s)
259+
market_data = _market_data(mid=100.0, bid=99.99, ask=100.01)
260+
md.get_market_data.return_value = market_data
261+
md.round_size.return_value = 1.0
262+
s.order_manager.bulk_place_orders.return_value = []
263+
264+
skew_calls = {"n": 0}
265+
266+
def counting_skew(_coin, _mid):
267+
skew_calls["n"] += 1
268+
return 0.0
269+
270+
s._calculate_inventory_skew = counting_skew
271+
s._record_mid_price = lambda coin, mid: None
272+
273+
# No ``ideal_prices`` kwarg -> internal compute path.
274+
s._place_orders("BTC")
275+
assert skew_calls["n"] == 1
276+
277+
def test_record_mid_price_fires_once_per_cycle(self):
278+
"""In BBO mode, the volatility buffer is updated exactly once per
279+
cycle even when ``_compute_ideal_prices`` is invoked from the
280+
run-loop tolerance check and the result is threaded into
281+
``_place_orders``."""
282+
s, _om, md, _tracker = _make_strategy(refresh_tolerance_bp=2.0)
283+
self._stub_for_place_orders(s)
284+
s.bbo_mode = True
285+
market_data = _market_data(mid=100.0, bid=99.95, ask=100.05)
286+
md.get_market_data.return_value = market_data
287+
md.round_size.return_value = 1.0
288+
s._calculate_inventory_skew = lambda coin, mid: 0.0
289+
s.order_manager.bulk_place_orders.return_value = []
290+
291+
record_calls = []
292+
s._record_mid_price = lambda coin, mid: record_calls.append((coin, mid))
293+
294+
# Cycle simulation.
295+
ideal = s._compute_ideal_prices("BTC")
296+
s._place_orders("BTC", ideal_prices=ideal)
297+
298+
# Buffer updated exactly once -- inside _compute_ideal_prices.
299+
assert len(record_calls) == 1
300+
assert record_calls[0] == ("BTC", 100.0)
301+
302+
def test_record_mid_price_skipped_when_not_bbo_mode(self):
303+
"""Non-BBO mode never updates the rolling volatility buffer
304+
(preserves pre-PR behaviour)."""
305+
s, _om, md, _tracker = _make_strategy(refresh_tolerance_bp=2.0)
306+
self._stub_for_place_orders(s)
307+
s.bbo_mode = False
308+
market_data = _market_data(mid=100.0, bid=99.99, ask=100.01)
309+
md.get_market_data.return_value = market_data
310+
md.round_size.return_value = 1.0
311+
s._calculate_inventory_skew = lambda coin, mid: 0.0
312+
s.order_manager.bulk_place_orders.return_value = []
313+
314+
record_calls = []
315+
s._record_mid_price = lambda coin, mid: record_calls.append((coin, mid))
316+
317+
ideal = s._compute_ideal_prices("BTC")
318+
s._place_orders("BTC", ideal_prices=ideal)
319+
320+
assert record_calls == []
321+
322+
201323
class TestPlaceOrdersOpenSidesGating:
202324
"""``_place_orders`` skips a side that already has a kept tracked order."""
203325

0 commit comments

Comments
 (0)