|
15 | 15 | prevent. |
16 | 16 | """ |
17 | 17 |
|
| 18 | +import logging |
| 19 | +import time |
18 | 20 | from unittest.mock import MagicMock |
19 | 21 |
|
20 | 22 | import pytest |
21 | 23 |
|
22 | 24 | from strategies.mm_order_tracker import OrderTracker |
23 | 25 |
|
24 | 26 |
|
| 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 | + |
25 | 111 | class TestCloseOrderInvariant: |
26 | 112 | """Close orders must not appear in ``_tracked_orders``.""" |
27 | 113 |
|
|
0 commit comments