Skip to content

Commit b208078

Browse files
keitajclaude
andauthored
refactor: add reason tag to cancel_all_orders_for_coin log line (#152)
``OrderTracker.cancel_all_orders_for_coin`` is invoked from five distinct control paths — real fill detection, WS fill feed, BBO guard, drain-mode entry, quiet-hour entry — but historically all of them emitted the same ``(post-fill cleanup)`` log message regardless of caller. On illiquid coins where the BBO-guard path dominates, ``grep "post-fill cleanup"`` produced counts that looked like fills were happening when in reality there were zero — actively misleading for any log-driven analysis (per-coin fill rate, monitoring dashboards, post-mortem investigations). This refactor adds an optional ``reason: str = "manual"`` argument to ``cancel_all_orders_for_coin``. Each caller passes a short snake_case tag (``fill`` / ``ws_fill`` / ``bbo_guard`` / ``drain`` / ``quiet_hour``) which is included verbatim in the log line: [mm] Cancelled X/Y orders for COIN (reason=bbo_guard): [oids...] The error path on ``bulk_cancel_orders`` failure carries the same tag, and the legacy "after fill" wording is removed from the error log since most callers are not fill-driven. The default ``"manual"`` preserves backward compatibility for tests / external callers that never passed the new argument. This is a pure observability change — order placement, cancellation, risk guards, and tracker state all behave identically. No CLI flag, env var, or config key is added. Tests: * New ``TestCancelAllOrdersForCoinReason`` (6 tests) pins: - explicit reason appears in the log - default = "manual" - each documented reason value round-trips - empty tracked list short-circuits without logging - ERROR path on API failure also carries the reason - tracked state is cleared regardless of reason * Existing assertions updated across ``test_bbo_guard``, ``test_drain_mode``, ``test_quiet_hours``, ``test_mm_cancel_on_fill`` and ``test_ws_fill_feed`` to pin the per-caller reason value at each integration point. Design doc: hip-3-farming-agent-team/docs/design-doc/20260505_cancel_all_orders_reason_logging.md Background investigation: hip-3-farming-agent-team/research/system_reviews/20260505_0557_copper_fill_investigation.md Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b8aa1d commit b208078

10 files changed

Lines changed: 121 additions & 19 deletions

strategies/market_making_strategy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ def run(self, coins: List[str]) -> None:
333333
# double-filling which doubles adverse selection cost.
334334
# NOTE: Does not fire when close_immediately=True because
335335
# positions are closed before the next detection pass.
336-
self._tracker.cancel_all_orders_for_coin(coin)
336+
self._tracker.cancel_all_orders_for_coin(coin, reason="fill")
337337
logger.info(f"[mm] Cancelled orders for {coin} after fill (prevent double-fill)")
338338

339339
# Track loss streaks: detect coins that just closed (had position last cycle, not now)
@@ -385,7 +385,7 @@ def run(self, coins: List[str]) -> None:
385385
if not self._was_drain:
386386
logger.info("[mm] Entering drain mode, cancelling all orders")
387387
for coin in coins:
388-
self._tracker.cancel_all_orders_for_coin(coin)
388+
self._tracker.cancel_all_orders_for_coin(coin, reason="drain")
389389
self._was_drain = True
390390
for coin in coins:
391391
try:
@@ -416,7 +416,7 @@ def run(self, coins: List[str]) -> None:
416416
utc_hour = datetime.now(timezone.utc).hour
417417
logger.info(f"[mm] Entering quiet hours (UTC {utc_hour}), cancelling all orders")
418418
for coin in coins:
419-
self._tracker.cancel_all_orders_for_coin(coin)
419+
self._tracker.cancel_all_orders_for_coin(coin, reason="quiet_hour")
420420
self._was_quiet = True
421421
# Still process existing positions but skip new order placement
422422
for coin in coins:

strategies/mm_order_tracker.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,26 @@ def get_refresh_stats(self, coin: Optional[str] = None) -> Dict[str, int]:
257257
"cancelled_age": sum(self._refresh_cancelled_age.values()),
258258
}
259259

260-
def cancel_all_orders_for_coin(self, coin: str) -> None:
260+
def cancel_all_orders_for_coin(self, coin: str, reason: str = "manual") -> None:
261261
"""Cancel all tracked orders for a coin.
262262
263-
Called when one side fills to prevent the opposite side from also
264-
filling (which would double adverse selection cost).
263+
Called from several distinct control paths — real fill detection,
264+
drain mode, quiet-hour entry, BBO guard, WS fill feed — and the
265+
*reason* argument is logged verbatim so observers can tell them
266+
apart downstream. The legacy log claimed "post-fill cleanup"
267+
regardless of caller, which was actively misleading on illiquid
268+
coins where the BBO-guard path dominates.
269+
270+
Recognised values (free-form, used as a string tag in the log):
271+
272+
* ``"fill"`` — real fill detection in the strategy main loop
273+
* ``"ws_fill"`` — WS fill feed instant cancel
274+
* ``"bbo_guard"`` — BBO guard cancelled both sides on rapid move
275+
* ``"drain"`` — drain-mode entry (graceful pre-shutdown)
276+
* ``"quiet_hour"`` — quiet-hour entry (full-stop mode)
277+
* ``"manual"`` (default) — unspecified caller / tests
278+
279+
New callers should pick a short snake_case tag and add it here.
265280
266281
Thread-safe: may be called from the WS fill feed thread.
267282
"""
@@ -277,11 +292,12 @@ def cancel_all_orders_for_coin(self, coin: str) -> None:
277292
cancelled = self.order_manager.bulk_cancel_orders(cancel_requests)
278293
logger.info(
279294
f"[mm] Cancelled {cancelled}/{len(cancel_requests)} orders for {coin} "
280-
f"(post-fill cleanup): {oid_list}"
295+
f"(reason={reason}): {oid_list}"
281296
)
282297
except Exception as e:
283298
logger.error(
284-
f"[mm] Error cancelling orders for {coin} after fill: {e}, oids: {oid_list}"
299+
f"[mm] Error cancelling orders for {coin} (reason={reason}): "
300+
f"{e}, oids: {oid_list}"
285301
)
286302

287303
def cancel_orders_by_side(self, coin: str, side: str) -> None:

tests/test_bbo_guard.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def test_large_change_triggers_cancel(self):
4343
# 3 bps change on bid — above threshold
4444
guard.on_l2_update("BTC", _make_levels(100.03, 100.05))
4545

46-
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC")
46+
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC", reason="bbo_guard")
4747
assert guard.stats["changes_detected"] == 1
4848
assert guard.stats["cancels_triggered"] == 1
4949

@@ -53,7 +53,7 @@ def test_ask_change_triggers_cancel(self):
5353
# Ask drops by 3 bps
5454
guard.on_l2_update("BTC", _make_levels(100.00, 99.99))
5555

56-
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC")
56+
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC", reason="bbo_guard")
5757

5858
def test_multiple_coins_independent(self):
5959
guard, tracker = _make_guard(threshold_bps=2.0)
@@ -64,7 +64,7 @@ def test_multiple_coins_independent(self):
6464
guard.on_l2_update("BTC", _make_levels(100.05, 100.07))
6565
guard.on_l2_update("ETH", _make_levels(3000.03, 3000.63))
6666

67-
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC")
67+
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC", reason="bbo_guard")
6868

6969

7070
class TestBboGuardEdgeCases:

tests/test_drain_mode.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ def test_drain_cancels_all_orders_on_entry(self):
7878
strategy.run(coins)
7979

8080
assert strategy._tracker.cancel_all_orders_for_coin.call_count == 2
81-
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:SP500')
82-
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:NVDA')
81+
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:SP500', reason="drain")
82+
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:NVDA', reason="drain")
8383
assert strategy._was_drain is True
8484

8585
def test_drain_no_repeat_cancel(self):

tests/test_mm_cancel_on_fill.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ def test_new_fill_triggers_cancel(self):
149149

150150
strategy.run(['BTC'])
151151

152-
strategy._tracker.cancel_all_orders_for_coin.assert_called_once_with('BTC')
152+
strategy._tracker.cancel_all_orders_for_coin.assert_called_once_with('BTC', reason="fill")
153153

154154
def test_no_fill_no_cancel(self):
155155
"""When a position already existed, no cancel is triggered."""

tests/test_mm_order_tracker.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,99 @@
1515
prevent.
1616
"""
1717

18+
import logging
19+
import time
1820
from unittest.mock import MagicMock
1921

2022
import pytest
2123

2224
from strategies.mm_order_tracker import OrderTracker
2325

2426

27+
class TestCancelAllOrdersForCoinReason:
28+
"""``cancel_all_orders_for_coin`` accepts a ``reason`` tag and includes
29+
it verbatim in the log so observers can distinguish the call site
30+
(real fill / drain / quiet hour / bbo guard / ws fill / manual).
31+
Pre-PR the message was hard-coded to "post-fill cleanup", which was
32+
actively misleading on the bbo-guard path.
33+
"""
34+
35+
def _make_tracker(self):
36+
om = MagicMock()
37+
om.bulk_cancel_orders.return_value = 1
38+
tracker = OrderTracker(
39+
om, refresh_interval_seconds=30, max_open_orders=4
40+
)
41+
tracker._tracked_orders["BTC"] = [(101, "B", time.monotonic())]
42+
return tracker, om
43+
44+
def test_explicit_reason_appears_in_log(self, caplog):
45+
tracker, _ = self._make_tracker()
46+
with caplog.at_level(logging.INFO, logger="strategies.mm_order_tracker"):
47+
tracker.cancel_all_orders_for_coin("BTC", reason="bbo_guard")
48+
msgs = [r.message for r in caplog.records if "Cancelled" in r.message]
49+
assert len(msgs) == 1
50+
assert "(reason=bbo_guard)" in msgs[0]
51+
assert "BTC" in msgs[0]
52+
# Legacy phrase must be gone.
53+
assert "post-fill cleanup" not in msgs[0]
54+
55+
def test_default_reason_is_manual(self, caplog):
56+
"""The default keeps backward-compatible call signatures (no kw)
57+
from older tests / external callers working."""
58+
tracker, _ = self._make_tracker()
59+
with caplog.at_level(logging.INFO, logger="strategies.mm_order_tracker"):
60+
tracker.cancel_all_orders_for_coin("BTC") # no reason
61+
msgs = [r.message for r in caplog.records if "Cancelled" in r.message]
62+
assert len(msgs) == 1
63+
assert "(reason=manual)" in msgs[0]
64+
65+
def test_each_reason_value_round_trips(self, caplog):
66+
"""Each documented reason string lands in the log unmodified."""
67+
for reason in ("fill", "ws_fill", "bbo_guard", "drain", "quiet_hour"):
68+
tracker, _ = self._make_tracker()
69+
caplog.clear()
70+
with caplog.at_level(logging.INFO, logger="strategies.mm_order_tracker"):
71+
tracker.cancel_all_orders_for_coin("BTC", reason=reason)
72+
msgs = [r.message for r in caplog.records if "Cancelled" in r.message]
73+
assert len(msgs) == 1, f"missing log for reason={reason}"
74+
assert f"(reason={reason})" in msgs[0]
75+
76+
def test_no_orders_emits_no_log(self, caplog):
77+
"""Empty tracked list short-circuits before logging — important
78+
because COPPER-style coins on bbo_guard path can fire many calls
79+
with nothing to cancel; we don't want a flood of empty cleanup
80+
lines in the log."""
81+
tracker, _ = self._make_tracker()
82+
tracker._tracked_orders["BTC"] = []
83+
with caplog.at_level(logging.INFO, logger="strategies.mm_order_tracker"):
84+
tracker.cancel_all_orders_for_coin("BTC", reason="bbo_guard")
85+
msgs = [r.message for r in caplog.records if "Cancelled" in r.message]
86+
assert msgs == []
87+
88+
def test_reason_appears_in_error_log_on_api_failure(self, caplog):
89+
tracker, om = self._make_tracker()
90+
om.bulk_cancel_orders.side_effect = Exception("API timeout")
91+
with caplog.at_level(logging.ERROR, logger="strategies.mm_order_tracker"):
92+
tracker.cancel_all_orders_for_coin("BTC", reason="ws_fill")
93+
errs = [
94+
r for r in caplog.records
95+
if r.levelno == logging.ERROR
96+
and "Error cancelling" in r.message
97+
]
98+
assert len(errs) == 1
99+
assert "(reason=ws_fill)" in errs[0].message
100+
# Old "after fill" wording is gone (it was wrong for non-fill paths).
101+
assert "after fill" not in errs[0].message
102+
103+
def test_tracked_state_cleared_regardless_of_reason(self):
104+
tracker, _ = self._make_tracker()
105+
tracker.cancel_all_orders_for_coin("BTC", reason="drain")
106+
# Same post-condition as the legacy code: tracked list is wiped
107+
# before bulk_cancel runs, so the API call cost is paid only once.
108+
assert tracker._tracked_orders["BTC"] == []
109+
110+
25111
class TestCloseOrderInvariant:
26112
"""Close orders must not appear in ``_tracked_orders``."""
27113

tests/test_quiet_hours.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ def test_quiet_hours_cancels_all_orders_on_entry(self, mock_dt):
8989

9090
# Should cancel all orders for each coin on entry
9191
assert strategy._tracker.cancel_all_orders_for_coin.call_count == 2
92-
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:SP500')
93-
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:NVDA')
92+
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:SP500', reason="quiet_hour")
93+
strategy._tracker.cancel_all_orders_for_coin.assert_any_call('xyz:NVDA', reason="quiet_hour")
9494
assert strategy._was_quiet is True
9595

9696
@patch('strategies.market_making_strategy.datetime')

tests/test_ws_fill_feed.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def test_live_fill_triggers_cancel(self):
6161
}
6262
})
6363

64-
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC")
64+
tracker.cancel_all_orders_for_coin.assert_called_once_with("BTC", reason="ws_fill")
6565
assert feed.stats["fills"] == 1
6666
assert feed.stats["cancels"] == 1
6767

ws/bbo_guard.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def _try_cancel(self, coin: str, change_bps: float) -> None:
9999
return # Rate limit: avoid spamming cancels
100100

101101
self._last_cancel_time[coin] = now
102-
self.order_tracker.cancel_all_orders_for_coin(coin)
102+
self.order_tracker.cancel_all_orders_for_coin(coin, reason="bbo_guard")
103103
self._cancels_triggered += 1
104104
logger.info(
105105
"[bbo-guard] BBO change %.1f bps for %s — cancelled orders",

ws/fill_feed.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def _on_fill(self, msg: Dict) -> None:
200200
# Cancel opposite-side orders for each filled coin.
201201
# OrderTracker.cancel_all_orders_for_coin is thread-safe (has its own lock).
202202
for coin in filled_coins:
203-
self.order_tracker.cancel_all_orders_for_coin(coin)
203+
self.order_tracker.cancel_all_orders_for_coin(coin, reason="ws_fill")
204204
self._cancel_count += 1
205205
logger.info(
206206
"[ws-fill] Instant cancel for %s (fill detected via WS)", coin

0 commit comments

Comments
 (0)