From be1011b9e602eeef8d4c83b62a15233104908448 Mon Sep 17 00:00:00 2001 From: keitaj Date: Sat, 2 May 2026 17:13:12 +0900 Subject: [PATCH] feat: add order refresh tolerance for queue-priority preservation Adds a tolerance-based gate to the market-making refresh loop so that an existing limit order is kept across cycles when its recorded price has drifted no more than ``refresh_tolerance_bp`` basis points from the current ideal price. This preserves Hyperliquid's price-time queue priority and reduces unnecessary cancel/replace API churn during quiet markets, while still re-quoting immediately when the price moves beyond tolerance. * OrderTracker now records the placement price alongside each tracked order (4-tuple) and exposes ``refresh_orders_with_tolerance`` plus ``get_open_sides`` to support the new gate. * Market-making strategy extracts ``_compute_ideal_prices`` from ``_place_orders`` so both the run-loop refresh check and order placement consume the same price calculation. * ``_place_orders`` skips the buy or sell side when a kept order from the previous cycle still occupies that side (prevents duplicate same-side quotes). * A safety-net ``refresh_max_age_seconds`` (default ``refresh_interval_seconds * 4``) caps the lifetime of a kept order even when its price stays within tolerance. Backward compatible: ``refresh_tolerance_bp`` defaults to ``0`` which preserves the original age-only cancel behaviour. ``record_order`` continues to accept three positional arguments; orders without a recorded price fall back to the legacy refresh interval check. Adds 24 new unit tests (decision-table parametrised cases, run-loop dispatch, side-gating, cumulative observability counters) and updates ``test_mm_order_tracker`` for backward-compat coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 + bot.py | 10 + strategies/market_making_strategy.py | 141 +++++++++--- strategies/mm_order_tracker.py | 189 ++++++++++++++-- tests/test_mm_order_tracker.py | 319 +++++++++++++++++++++++++++ tests/test_refresh_tolerance.py | 285 ++++++++++++++++++++++++ validation/strategy_validator.py | 10 + 7 files changed, 911 insertions(+), 47 deletions(-) create mode 100644 tests/test_refresh_tolerance.py diff --git a/README.md b/README.md index 7656b67..3421903 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,8 @@ The `market_making` strategy uses **progressive close pricing**: as a position a **BBO mode** (`--bbo-mode`): Places orders at the best bid/ask instead of `mid ± spread_bps`. On Hyperliquid, market spreads are typically 0.1–2 bps, so even `SPREAD_BPS=5` places orders 4–5 bps away from BBO, resulting in low fill rates. BBO mode improves fill rates by tracking the current best prices. Use `--bbo-offset-bps N` to place orders N bps behind BBO (default: 0 = at BBO). Falls back to `mid ± spread_bps` when BBO is unavailable. +**Refresh tolerance** (`--refresh-tolerance-bp`): Keep an existing order across cycles when its recorded price drifted no more than this many basis points from the current ideal price. Reduces unnecessary cancel/replace traffic and preserves queue priority on Hyperliquid's price–time matching when the market is quiet. The cancel still fires immediately when the drift exceeds tolerance. A safety-net upper bound on order age applies independently via `--refresh-max-age-seconds` (default: `refresh_interval_seconds * 4`). Default: `0` (disabled, age-only behaviour preserved for full backward compatibility). + **Per-coin overrides** (`--coin-offset-overrides`, `--coin-spread-overrides`, `--coin-size-overrides`, `--coin-unrealized-loss-overrides`): Override BBO offset, spread, order size, or the unrealized-loss early-close threshold per coin. Format: `"SP500:0.5,MSFT:3"`. Supports both bare names and DEX-prefixed names (`xyz:SP500:0.5`). Unspecified coins use the global default. Use `--coin-size-overrides` to set per-coin order size in USD (e.g., `"TSLA:150,NVDA:150"`); setting a coin to `0` skips orders for that coin. Use `--coin-unrealized-loss-overrides` to relax the threshold on low-vol coins (`"INTC:25"`) or tighten it on volatile ones (`"OIL:10"`); setting a coin to `0` disables the unrealized-loss feature for that coin. **Quiet hours** (`--quiet-hours-utc`): Stop or widen quoting during specific UTC hours (e.g., `"17"` or `"17,18"`). Default: stop quoting entirely. With `--quiet-hours-spread-multiplier N`, widens spread by Nx instead. Positions are still managed during quiet hours. @@ -547,6 +549,8 @@ strategies: order_size_usd: 50 # --order-size-usd max_open_orders: 4 # --max-open-orders refresh_interval_seconds: 30 # --refresh-interval + refresh_tolerance_bp: 0 # --refresh-tolerance-bp (keep an order across cycles when its price drift <= this many bps; 0 = disabled, age-only) + refresh_max_age_seconds: null # --refresh-max-age-seconds (safety-net upper bound on age of a kept order; null = refresh_interval_seconds * 4) close_immediately: true # --no-close-immediately (flag inverts this) max_position_age_seconds: 120 # --max-position-age maker_only: false # --maker-only diff --git a/bot.py b/bot.py index 243fe89..2a58a87 100644 --- a/bot.py +++ b/bot.py @@ -182,6 +182,7 @@ def __init__(self, strategy_name: str = "simple_ma", coins: Optional[List[str]] 'order_size_usd': 50, 'max_open_orders': 4, 'refresh_interval_seconds': 30, + 'refresh_tolerance_bp': 0, 'close_immediately': True, 'maker_only': False, 'max_positions': 3, @@ -992,6 +993,13 @@ def stop(self): parser.add_argument('--max-open-orders', type=int, help='Max concurrent open orders (market_making, default: 4)') parser.add_argument('--refresh-interval', dest='refresh_interval_seconds', type=float, help='Seconds before cancelling stale orders (market_making, default: 30)') + parser.add_argument('--refresh-tolerance-bp', type=float, + help='Keep an order across cycles when its price drifted no more than this many ' + 'basis points from the current ideal price (market_making, default: 0 = disabled). ' + 'Preserves queue priority and reduces API churn when the market is quiet.') + parser.add_argument('--refresh-max-age-seconds', type=float, + help='Safety-net upper bound on the age of a kept order even when its price is still ' + 'within tolerance (market_making, default: refresh_interval_seconds * 4).') parser.add_argument('--no-close-immediately', action='store_true', default=False, help='Disable immediate position closing (market_making)') parser.add_argument('--drain-flag-file', type=str, @@ -1202,6 +1210,8 @@ def stop(self): 'market_making': [ 'spread_bps', 'order_size_usd', 'max_open_orders', 'refresh_interval_seconds', + 'refresh_tolerance_bp', + 'refresh_max_age_seconds', 'max_position_age_seconds', 'taker_fallback_age_seconds', 'aggressive_loss_bps', diff --git a/strategies/market_making_strategy.py b/strategies/market_making_strategy.py index 6cad8e3..1ab94fc 100644 --- a/strategies/market_making_strategy.py +++ b/strategies/market_making_strategy.py @@ -192,10 +192,32 @@ def __init__(self, market_data_manager, order_manager, config: Dict) -> None: self._prev_position_coins: set = set() # coins that had positions last cycle self._prev_positions: Dict[str, Dict] = {} # snapshot for loss streak detection + # ---- Refresh tolerance (preserve queue priority on small price drift) ---- # + # When ``refresh_tolerance_bp > 0``, an order is kept across cycles + # as long as both (a) its recorded price drifted no more than + # ``refresh_tolerance_bp`` basis points from the current ideal + # price, and (b) its age is below ``refresh_max_age_seconds``. + # ``refresh_tolerance_bp == 0`` (default) preserves the original + # age-only behaviour (full backward compatibility). + self.refresh_tolerance_bp: float = max( + 0.0, float(config.get('refresh_tolerance_bp', 0)) + ) + refresh_interval = float(config.get('refresh_interval_seconds', 30)) + raw_max_age = config.get('refresh_max_age_seconds', None) + if raw_max_age is None: + self.refresh_max_age_seconds: float = max(refresh_interval * 4.0, refresh_interval) + else: + self.refresh_max_age_seconds = max(float(raw_max_age), refresh_interval) + if getattr(self, 'refresh_tolerance_bp', 0) > 0: + logger.info( + f"[mm] Refresh tolerance enabled: tolerance={self.refresh_tolerance_bp}bp, " + f"max_age={self.refresh_max_age_seconds}s" + ) + # ---- Delegates ---- # self._tracker = OrderTracker( order_manager=order_manager, - refresh_interval_seconds=config.get('refresh_interval_seconds', 30), + refresh_interval_seconds=refresh_interval, max_open_orders=self.max_open_orders, ) self._closer = PositionCloser( @@ -388,7 +410,26 @@ def run(self, coins: List[str]) -> None: # No position — normal MM flow close_oid = self._closer.get_close_oid(coin) - self._tracker.cancel_stale_orders(coin, close_oid=close_oid) + + if getattr(self, 'refresh_tolerance_bp', 0) > 0: + ideal = self._compute_ideal_prices(coin) + if ideal is None: + # Fall back to age-only when ideal price is unavailable + self._tracker.cancel_stale_orders(coin, close_oid=close_oid) + else: + ideal_buy, ideal_sell = ideal + self._tracker.refresh_orders_with_tolerance( + coin, + ideal_prices={ + OrderSide.BUY.value: ideal_buy, + OrderSide.SELL.value: ideal_sell, + }, + tolerance_bp=self.refresh_tolerance_bp, + max_age_seconds=self.refresh_max_age_seconds, + close_oid=close_oid, + ) + else: + self._tracker.cancel_stale_orders(coin, close_oid=close_oid) # Check max positions using active coin count active_count = self._tracker.active_coins( @@ -603,64 +644,50 @@ def _get_dynamic_position_age(self, coin: str) -> Optional[float]: return age - def _place_orders(self, coin: str) -> None: - """Place a buy and a sell limit order. + def _compute_ideal_prices(self, coin: str) -> Optional[Tuple[float, float]]: + """Compute current ideal ``(buy_price, sell_price)`` for ``coin``. - In BBO mode, orders are placed at or near the best bid/ask. - Otherwise, orders are placed symmetrically around mid price at - ``spread_bps``. Uses ``bulk_place_orders`` for a single API call. + Mirrors the price computation in :meth:`_place_orders` but is a + pure helper (no side effects on rolling buffers, no order placement). + Used by the run loop to evaluate refresh-tolerance drift before + deciding whether to keep existing orders. Returns ``None`` when the + ideal price cannot be determined (no market data, mid <= 0). """ - from order_manager import Order - - if coin in self._closer.tracked_coins: - return - market_data = self.market_data.get_market_data(coin) if not market_data: - logger.warning(f"[mm] No market data for {coin}, skipping") - return + return None mid_price = market_data.mid_price if mid_price <= 0: - return + return None rp = self.market_data.price_rounding_params(coin) if self.bbo_mode and market_data.bid > 0 and market_data.ask > 0: - # BBO-following mode: place at/near best bid and ask - self._record_mid_price(coin, mid_price) base_offset = self._get_coin_offset(coin) if self.vol_adjust_enabled: effective_offset_bps = self._get_volatility_adjusted_offset(coin, base_offset) else: effective_offset_bps = base_offset - # Widen spread during quiet hours (spread-multiplier mode) if self._quiet_spread_multiplier > 0 and self._is_quiet_hour(): effective_offset_bps *= self._quiet_spread_multiplier - # Hourly spread schedule hourly_mult = self._get_hourly_spread_multiplier() if hourly_mult != 1.0: effective_offset_bps *= hourly_mult - # Micro-price asymmetric offset buy_offset_bps, sell_offset_bps = self._calculate_microprice_offsets( coin, effective_offset_bps ) buy_price = round_price(market_data.bid * (1 - buy_offset_bps / 10_000), *rp) sell_price = round_price(market_data.ask * (1 + sell_offset_bps / 10_000), *rp) else: - # Fallback: mid ± spread. Also used when BBO is unavailable - # (bid/ask=0) even in bbo_mode. Maker-only clamping below - # ensures Alo orders don't cross the spread. coin_spread = self._get_coin_spread(coin) spread_offset = mid_price * (coin_spread / 10_000) raw_buy = mid_price - spread_offset raw_sell = mid_price + spread_offset - # Widen spread during quiet hours (spread-multiplier mode) if self._quiet_spread_multiplier > 0 and self._is_quiet_hour(): extra = spread_offset * (self._quiet_spread_multiplier - 1) raw_buy -= extra raw_sell += extra - # Hourly spread schedule hourly_mult = self._get_hourly_spread_multiplier() if hourly_mult != 1.0 and hourly_mult > 0: extra = spread_offset * (hourly_mult - 1) @@ -668,22 +695,55 @@ def _place_orders(self, coin: str) -> None: raw_sell += extra buy_price = round_price(raw_buy, *rp) sell_price = round_price(raw_sell, *rp) - # Clamp prices to stay outside BBO for maker-only (Alo) orders if self.maker_only and market_data.bid > 0 and market_data.ask > 0: if buy_price >= market_data.bid: buy_price = round_price(market_data.bid * (1 - BBO_OFFSET), *rp) if sell_price <= market_data.ask: sell_price = round_price(market_data.ask * (1 + BBO_OFFSET), *rp) - # Inventory skew: shift both prices to encourage position reduction. - # Applied after BBO/spread pricing intentionally — skew may push - # prices beyond BBO bounds (e.g. sell below ask) which is desired - # to accelerate inventory reduction via more aggressive fills. + # Inventory skew (same shift applied to both legs) skew = self._calculate_inventory_skew(coin, mid_price) if skew != 0.0: skew_mult = skew / 10_000 buy_price = round_price(buy_price * (1 - skew_mult), *rp) sell_price = round_price(sell_price * (1 - skew_mult), *rp) + + return buy_price, sell_price + + def _place_orders(self, coin: str) -> None: + """Place a buy and a sell limit order. + + In BBO mode, orders are placed at or near the best bid/ask. + Otherwise, orders are placed symmetrically around mid price at + ``spread_bps``. Uses ``bulk_place_orders`` for a single API call. + """ + from order_manager import Order + + if coin in self._closer.tracked_coins: + return + + market_data = self.market_data.get_market_data(coin) + if not market_data: + logger.warning(f"[mm] No market data for {coin}, skipping") + return + + mid_price = market_data.mid_price + if mid_price <= 0: + return + + # Volatility buffer is updated here so it occurs once per placement + # cycle even when ``_compute_ideal_prices`` is also called from the + # run loop (which is a pure helper). + if self.bbo_mode and market_data.bid > 0 and market_data.ask > 0: + self._record_mid_price(coin, mid_price) + + prices = self._compute_ideal_prices(coin) + if prices is None: + return + buy_price, sell_price = prices + + skew = self._calculate_inventory_skew(coin, mid_price) + if skew != 0.0: logger.debug(f"[mm] Inventory skew {coin}: {skew:.1f}bps") size = self.calculate_position_size(coin, {}) @@ -696,6 +756,13 @@ def _place_orders(self, coin: str) -> None: return current_count = self._tracker.get_order_count(coin) + # When refresh tolerance is enabled, an order may have been kept + # across cycles -- avoid placing a duplicate quote on the same side. + open_sides = ( + self._tracker.get_open_sides(coin) + if getattr(self, 'refresh_tolerance_bp', 0) > 0 + else set() + ) sides_and_prices = [] # L2 book imbalance guard: skip the side that is likely to get adversely selected. @@ -712,10 +779,18 @@ def _place_orders(self, coin: str) -> None: skip_sell = True logger.debug(f"[mm] {coin} skipping SELL (book imbalance {imb:.2f})") - if current_count < self.max_open_orders and not skip_buy: + if ( + current_count < self.max_open_orders + and not skip_buy + and OrderSide.BUY.value not in open_sides + ): sides_and_prices.append((OrderSide.BUY, buy_price)) - if current_count + len(sides_and_prices) < self.max_open_orders and not skip_sell: + if ( + current_count + len(sides_and_prices) < self.max_open_orders + and not skip_sell + and OrderSide.SELL.value not in open_sides + ): sides_and_prices.append((OrderSide.SELL, sell_price)) if not sides_and_prices: @@ -735,7 +810,7 @@ def _place_orders(self, coin: str) -> None: for (side, price), order in zip(sides_and_prices, results): if order and order.id is not None: - self._tracker.record_order(coin, order.id, side.value) + self._tracker.record_order(coin, order.id, side.value, price=price) self._orders_placed += 1 self._orders_placed_per_coin[coin] += 1 logger.info( diff --git a/strategies/mm_order_tracker.py b/strategies/mm_order_tracker.py index de07493..ced7c38 100644 --- a/strategies/mm_order_tracker.py +++ b/strategies/mm_order_tracker.py @@ -9,6 +9,14 @@ logger = logging.getLogger(__name__) +# Per-tracked-order tuple: (oid, side, place_time, price) +# ``price`` is the limit price recorded at placement time; used by +# ``refresh_orders_with_tolerance`` to decide whether the order is still +# close enough to the current ideal price to keep (preserve queue priority). +# Defaults to 0.0 when unspecified (tolerance-based keep is then disabled +# for that order and it falls back to age-based cancellation). +TrackedOrder = Tuple[int, str, float, float] + class OrderTracker: """Tracks live market-making orders per coin, cancels stale ones.""" @@ -18,21 +26,41 @@ def __init__(self, order_manager, refresh_interval_seconds: float, max_open_orde self.refresh_interval_seconds = refresh_interval_seconds self.max_open_orders = max_open_orders - # coin -> list of (oid, side, place_time) - self._tracked_orders: Dict[str, List[Tuple[int, str, float]]] = {} + # coin -> list of (oid, side, place_time, price) + self._tracked_orders: Dict[str, List[TrackedOrder]] = {} self._last_order_time: Dict[str, float] = {} self._lock = threading.Lock() + # Counters for observability (cumulative, never reset). + self._refresh_kept: Dict[str, int] = {} + self._refresh_cancelled_drift: Dict[str, int] = {} + self._refresh_cancelled_age: Dict[str, int] = {} + def get_order_count(self, coin: str) -> int: with self._lock: return len(self._tracked_orders.get(coin, [])) - def record_order(self, coin: str, oid: int, side: str) -> None: + def get_open_sides(self, coin: str) -> Set[str]: + """Return set of sides with currently tracked orders for ``coin``. + + Used by ``_place_orders`` to skip placing a new quote on a side + that already has a kept order (avoids duplicate same-side orders). + """ + with self._lock: + return {entry[1] for entry in self._tracked_orders.get(coin, [])} + + def record_order(self, coin: str, oid: int, side: str, price: float = 0.0) -> None: + """Record a newly placed order. + + ``price`` is optional for backward compatibility with callers that + do not yet provide it (tolerance-based keep will be disabled for + such orders, which fall back to age-only cancellation). + """ now = time.monotonic() with self._lock: if coin not in self._tracked_orders: self._tracked_orders[coin] = [] - self._tracked_orders[coin].append((oid, side, now)) + self._tracked_orders[coin].append((oid, side, now, float(price))) self._last_order_time[coin] = now def active_coins(self, positions: Dict, open_positions_keys: Set[str]) -> int: @@ -56,7 +84,7 @@ def cancel_stale_orders(self, coin: str, close_oid: Optional[int] = None) -> Non return now = time.monotonic() - still_active: List[Tuple[int, str, float]] = [] + still_active: List[TrackedOrder] = [] try: open_orders = self.order_manager.get_open_orders(coin) @@ -67,21 +95,21 @@ def cancel_stale_orders(self, coin: str, close_oid: Optional[int] = None) -> Non to_cancel: List[Tuple[int, str]] = [] # (oid, side) pairs to bulk cancel - for oid, side, place_time in tracked: + for oid, side, place_time, price in tracked: if oid not in open_oids: logger.debug(f"[mm] Order {oid} ({side} {coin}) no longer open") continue # Never cancel a close order from here if close_oid is not None and oid == close_oid: - still_active.append((oid, side, place_time)) + still_active.append((oid, side, place_time, price)) continue age = now - place_time if age >= self.refresh_interval_seconds: to_cancel.append((oid, side)) else: - still_active.append((oid, side, place_time)) + still_active.append((oid, side, place_time, price)) if to_cancel: cancel_requests = [{"coin": coin, "oid": oid} for oid, _ in to_cancel] @@ -96,6 +124,139 @@ def cancel_stale_orders(self, coin: str, close_oid: Optional[int] = None) -> Non with self._lock: self._tracked_orders[coin] = still_active + def refresh_orders_with_tolerance( + self, + coin: str, + ideal_prices: Dict[str, float], + tolerance_bp: float, + max_age_seconds: float, + close_oid: Optional[int] = None, + ) -> Dict[str, int]: + """Selectively cancel orders based on price drift and age. + + For each tracked order, an order is **kept** when both: + * its recorded ``price`` is within ``tolerance_bp`` basis points of + ``ideal_prices[side]``, and + * its age is below ``max_age_seconds``. + + Otherwise it is cancelled. This preserves queue priority for orders + whose price is still fresh, while immediately re-quoting when the + market has moved beyond tolerance. The ``max_age_seconds`` clamp acts + as a safety net to ensure no order is kept indefinitely. + + Orders matching ``close_oid`` are always kept (close-side orders are + owned by ``MMPositionCloser``). + + Orders for which a recorded price is unavailable (``price <= 0``) or + whose side is missing from ``ideal_prices`` fall back to age-only + cancellation (using ``self.refresh_interval_seconds``). + + Returns a dict ``{"kept": N, "cancelled_drift": M, "cancelled_age": K}`` + for the current call (cumulative counters are also updated and + accessible via :meth:`get_refresh_stats`). + """ + result = {"kept": 0, "cancelled_drift": 0, "cancelled_age": 0} + + with self._lock: + tracked = list(self._tracked_orders.get(coin, [])) + if not tracked: + return result + + now = time.monotonic() + still_active: List[TrackedOrder] = [] + + try: + open_orders = self.order_manager.get_open_orders(coin) + open_oids = {int(o['oid']) for o in open_orders} + except API_ERRORS as e: + logger.error(f"[mm] Error fetching open orders for {coin}: {e}") + return result + + to_cancel: List[Tuple[int, str, str]] = [] # (oid, side, reason) + + for oid, side, place_time, price in tracked: + if oid not in open_oids: + logger.debug(f"[mm] Order {oid} ({side} {coin}) no longer open") + continue + + # Never touch a close order from here. + if close_oid is not None and oid == close_oid: + still_active.append((oid, side, place_time, price)) + continue + + age = now - place_time + ideal = ideal_prices.get(side) + + # Fallback to age-only when we cannot evaluate drift. + if ideal is None or ideal <= 0 or price <= 0: + if age >= self.refresh_interval_seconds: + to_cancel.append((oid, side, "age")) + else: + still_active.append((oid, side, place_time, price)) + continue + + drift_bp = abs(price - ideal) / ideal * 10_000.0 + + if drift_bp <= tolerance_bp and age < max_age_seconds: + still_active.append((oid, side, place_time, price)) + result["kept"] += 1 + logger.debug( + f"[mm] {coin} keeping {side} oid={oid} drift={drift_bp:.2f}bp " + f"<= tol={tolerance_bp}bp age={age:.1f}s" + ) + elif age >= max_age_seconds and drift_bp <= tolerance_bp: + # Tolerance ok but exceeded the safety-net age clamp. + to_cancel.append((oid, side, "age")) + else: + to_cancel.append((oid, side, "drift")) + + if to_cancel: + cancel_requests = [{"coin": coin, "oid": oid} for oid, _, _ in to_cancel] + cancelled = self.order_manager.bulk_cancel_orders(cancel_requests) + for oid, side, reason in to_cancel: + if reason == "drift": + result["cancelled_drift"] += 1 + else: + result["cancelled_age"] += 1 + logger.info( + f"[mm] Cancelled {reason} {side} order {oid} for {coin}" + ) + if cancelled < len(to_cancel): + logger.debug( + f"[mm] Bulk cancel: {cancelled}/{len(to_cancel)} succeeded for {coin}" + ) + + with self._lock: + self._tracked_orders[coin] = still_active + + # Update cumulative counters. + if result["kept"]: + self._refresh_kept[coin] = self._refresh_kept.get(coin, 0) + result["kept"] + if result["cancelled_drift"]: + self._refresh_cancelled_drift[coin] = ( + self._refresh_cancelled_drift.get(coin, 0) + result["cancelled_drift"] + ) + if result["cancelled_age"]: + self._refresh_cancelled_age[coin] = ( + self._refresh_cancelled_age.get(coin, 0) + result["cancelled_age"] + ) + + return result + + def get_refresh_stats(self, coin: Optional[str] = None) -> Dict[str, int]: + """Return cumulative refresh counters for one coin or all coins combined.""" + if coin is not None: + return { + "kept": self._refresh_kept.get(coin, 0), + "cancelled_drift": self._refresh_cancelled_drift.get(coin, 0), + "cancelled_age": self._refresh_cancelled_age.get(coin, 0), + } + return { + "kept": sum(self._refresh_kept.values()), + "cancelled_drift": sum(self._refresh_cancelled_drift.values()), + "cancelled_age": sum(self._refresh_cancelled_age.values()), + } + def cancel_all_orders_for_coin(self, coin: str) -> None: """Cancel all tracked orders for a coin. @@ -110,8 +271,8 @@ def cancel_all_orders_for_coin(self, coin: str) -> None: if not tracked: return - cancel_requests = [{"coin": coin, "oid": oid} for oid, _side, _t in tracked] - oid_list = [oid for oid, _, _ in tracked] + cancel_requests = [{"coin": coin, "oid": entry[0]} for entry in tracked] + oid_list = [entry[0] for entry in tracked] try: cancelled = self.order_manager.bulk_cancel_orders(cancel_requests) logger.info( @@ -133,15 +294,15 @@ def cancel_orders_by_side(self, coin: str, side: str) -> None: """ with self._lock: tracked = self._tracked_orders.get(coin, []) - to_cancel = [(oid, s, t) for oid, s, t in tracked if s == side] - remaining = [(oid, s, t) for oid, s, t in tracked if s != side] + to_cancel = [entry for entry in tracked if entry[1] == side] + remaining = [entry for entry in tracked if entry[1] != side] self._tracked_orders[coin] = remaining if not to_cancel: return - cancel_requests = [{"coin": coin, "oid": oid} for oid, _, _ in to_cancel] - oid_list = [oid for oid, _, _ in to_cancel] + cancel_requests = [{"coin": coin, "oid": entry[0]} for entry in to_cancel] + oid_list = [entry[0] for entry in to_cancel] try: cancelled = self.order_manager.bulk_cancel_orders(cancel_requests) logger.info( diff --git a/tests/test_mm_order_tracker.py b/tests/test_mm_order_tracker.py index 0819dfe..60dbc8b 100644 --- a/tests/test_mm_order_tracker.py +++ b/tests/test_mm_order_tracker.py @@ -17,6 +17,8 @@ from unittest.mock import MagicMock +import pytest + from strategies.mm_order_tracker import OrderTracker @@ -85,3 +87,320 @@ def test_get_order_count_excludes_unregistered_close_oid(self) -> None: # close_oid 5002 lives only in MMPositionCloser -- not registered here. assert tracker.get_order_count("xyz:NVDA") == 2 + + +class TestRecordOrderBackwardCompat: + """``record_order`` must accept calls without a price for backward compat.""" + + def test_record_order_without_price(self) -> None: + """Legacy callers omit ``price``; the tracker must still register the order.""" + order_manager = MagicMock() + tracker = OrderTracker(order_manager, refresh_interval_seconds=60, max_open_orders=4) + + tracker.record_order("BTC", oid=10, side="B") + + assert tracker.get_order_count("BTC") == 1 + assert tracker.get_open_sides("BTC") == {"B"} + + def test_record_order_with_price(self) -> None: + """New callers pass ``price`` to enable tolerance-based keep.""" + order_manager = MagicMock() + tracker = OrderTracker(order_manager, refresh_interval_seconds=60, max_open_orders=4) + + tracker.record_order("BTC", oid=11, side="A", price=42_000.5) + + assert tracker.get_order_count("BTC") == 1 + assert tracker.get_open_sides("BTC") == {"A"} + + +class TestRefreshOrdersWithTolerance: + """Behaviour of ``refresh_orders_with_tolerance``. + + Time is controlled by monkeypatching ``time.monotonic`` so order ages + are deterministic regardless of how fast the test runs. + """ + + def _make_tracker(self, monkeypatch, now: float = 1000.0): + """Helper: tracker with a controllable monotonic clock.""" + from strategies import mm_order_tracker as tracker_mod + + clock = {"t": now} + monkeypatch.setattr(tracker_mod.time, "monotonic", lambda: clock["t"]) + + order_manager = MagicMock() + order_manager.bulk_cancel_orders.return_value = 0 + tracker = OrderTracker(order_manager, refresh_interval_seconds=30, max_open_orders=4) + return tracker, order_manager, clock + + def test_keeps_order_within_tolerance_and_age(self, monkeypatch) -> None: + """Order whose price drift is within tolerance and age < max_age is kept.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=1, side="B", price=100.0) + order_manager.get_open_orders = MagicMock(return_value=[{"oid": 1}]) + + # Age 5s, drift 5bp ((100.05 - 100) / 100 * 1e4 = 5) + clock["t"] += 5.0 + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.05, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + + assert result == {"kept": 1, "cancelled_drift": 0, "cancelled_age": 0} + assert tracker.get_order_count("BTC") == 1 + order_manager.bulk_cancel_orders.assert_not_called() + + def test_cancels_when_drift_exceeds_tolerance(self, monkeypatch) -> None: + """Drift > tolerance triggers immediate cancel even when young.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=2, side="A", price=100.0) + order_manager.get_open_orders = MagicMock(return_value=[{"oid": 2}]) + + # Age 1s, drift 100bp ((101 - 100) / 100 * 1e4 = 100) + clock["t"] += 1.0 + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 99.0, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + + assert result == {"kept": 0, "cancelled_drift": 1, "cancelled_age": 0} + assert tracker.get_order_count("BTC") == 0 + order_manager.bulk_cancel_orders.assert_called_once() + + def test_cancels_when_age_exceeds_max_age(self, monkeypatch) -> None: + """Even within tolerance, an order older than max_age is cancelled.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=3, side="B", price=100.0) + order_manager.get_open_orders = MagicMock(return_value=[{"oid": 3}]) + + # Age 200s, no drift + clock["t"] += 200.0 + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.0, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + + assert result == {"kept": 0, "cancelled_drift": 0, "cancelled_age": 1} + assert tracker.get_order_count("BTC") == 0 + + def test_close_oid_is_never_cancelled(self, monkeypatch) -> None: + """``close_oid`` is preserved regardless of drift or age.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=4, side="B", price=100.0) + order_manager.get_open_orders = MagicMock(return_value=[{"oid": 4}]) + + # Both drift > tolerance AND age > max_age -- still must keep. + clock["t"] += 500.0 + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 90.0, "A": 110.0}, + tolerance_bp=1.0, + max_age_seconds=60.0, + close_oid=4, + ) + + assert tracker.get_order_count("BTC") == 1 + # close_oid is kept implicitly (not counted as kept since it bypasses + # the tolerance evaluation entirely). + assert result["cancelled_drift"] == 0 + assert result["cancelled_age"] == 0 + order_manager.bulk_cancel_orders.assert_not_called() + + def test_falls_back_to_age_when_price_unrecorded(self, monkeypatch) -> None: + """Orders recorded without price (legacy/zero) fall back to age-only.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=5, side="B") # no price -> 0.0 + order_manager.get_open_orders = MagicMock(return_value=[{"oid": 5}]) + + # Age below refresh_interval -> keep + clock["t"] += 10.0 + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.0, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + assert tracker.get_order_count("BTC") == 1 + # Not counted as kept (it's the fallback path), but still in tracker. + assert result["kept"] == 0 + order_manager.bulk_cancel_orders.assert_not_called() + + # Age >= refresh_interval -> cancel + clock["t"] += 30.0 + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.0, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + assert tracker.get_order_count("BTC") == 0 + + def test_drops_orders_no_longer_open_on_exchange(self, monkeypatch) -> None: + """Orders absent from the exchange's open-orders list are dropped silently.""" + tracker, order_manager, _ = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=6, side="B", price=100.0) + # Exchange reports no open orders -> our tracked oid 6 was filled or + # cancelled out-of-band. + order_manager.get_open_orders = MagicMock(return_value=[]) + + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.0, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + + assert tracker.get_order_count("BTC") == 0 + assert result == {"kept": 0, "cancelled_drift": 0, "cancelled_age": 0} + order_manager.bulk_cancel_orders.assert_not_called() + + def test_partial_evaluation_when_only_one_side_has_ideal(self, monkeypatch) -> None: + """Sides without an ideal price fall back to age-only; sides with one + get tolerance evaluation.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=7, side="B", price=100.0) + tracker.record_order("BTC", oid=8, side="A", price=200.0) + order_manager.get_open_orders = MagicMock( + return_value=[{"oid": 7}, {"oid": 8}] + ) + + # Provide ideal only for the buy side; the ask side falls back to age. + clock["t"] += 5.0 + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.0}, # ask not provided + tolerance_bp=5.0, + max_age_seconds=120.0, + ) + + # Both kept: B due to tolerance, A due to age below refresh_interval. + assert tracker.get_order_count("BTC") == 2 + + def test_cumulative_stats_accumulate_across_calls(self, monkeypatch) -> None: + """``get_refresh_stats`` reflects all calls cumulatively.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=9, side="B", price=100.0) + tracker.record_order("BTC", oid=10, side="A", price=101.0) + order_manager.get_open_orders = MagicMock( + return_value=[{"oid": 9}, {"oid": 10}] + ) + + # Cycle 1: both within tolerance. + clock["t"] += 5.0 + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.0, "A": 101.0}, + tolerance_bp=10.0, + max_age_seconds=120.0, + ) + + stats = tracker.get_refresh_stats() + assert stats["kept"] == 2 + assert stats["cancelled_drift"] == 0 + assert stats["cancelled_age"] == 0 + + # Cycle 2: ideal moves -> both cancelled by drift. + # (Re-register so they still exist after the first cycle's keep.) + order_manager.get_open_orders = MagicMock( + return_value=[{"oid": 9}, {"oid": 10}] + ) + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 110.0, "A": 91.0}, + tolerance_bp=1.0, + max_age_seconds=120.0, + ) + + stats = tracker.get_refresh_stats() + assert stats["kept"] == 2 + assert stats["cancelled_drift"] == 2 + + def test_tolerance_zero_cancels_any_drift(self, monkeypatch) -> None: + """``tolerance_bp == 0`` keeps only orders at exactly the ideal price.""" + tracker, order_manager, clock = self._make_tracker(monkeypatch) + tracker.record_order("BTC", oid=20, side="B", price=100.0) + tracker.record_order("BTC", oid=21, side="A", price=101.0) + order_manager.get_open_orders = MagicMock( + return_value=[{"oid": 20}, {"oid": 21}] + ) + + # Tiny drift on bid; ask exact. + clock["t"] += 1.0 + result = tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.001, "A": 101.0}, + tolerance_bp=0.0, + max_age_seconds=120.0, + ) + + assert result["cancelled_drift"] == 1 + assert result["kept"] == 1 + assert tracker.get_open_sides("BTC") == {"A"} + + +class TestGetOpenSides: + """``get_open_sides`` reflects currently tracked orders by side.""" + + def test_empty_when_no_orders(self) -> None: + order_manager = MagicMock() + tracker = OrderTracker(order_manager, refresh_interval_seconds=30, max_open_orders=4) + assert tracker.get_open_sides("BTC") == set() + + def test_reports_both_sides(self) -> None: + order_manager = MagicMock() + tracker = OrderTracker(order_manager, refresh_interval_seconds=30, max_open_orders=4) + tracker.record_order("BTC", oid=1, side="B", price=100.0) + tracker.record_order("BTC", oid=2, side="A", price=101.0) + assert tracker.get_open_sides("BTC") == {"B", "A"} + + def test_isolated_per_coin(self) -> None: + order_manager = MagicMock() + tracker = OrderTracker(order_manager, refresh_interval_seconds=30, max_open_orders=4) + tracker.record_order("BTC", oid=1, side="B", price=100.0) + tracker.record_order("ETH", oid=2, side="A", price=2_000.0) + assert tracker.get_open_sides("BTC") == {"B"} + assert tracker.get_open_sides("ETH") == {"A"} + assert tracker.get_open_sides("SOL") == set() + + +@pytest.mark.parametrize( + "tolerance_bp,age_seconds,expect_keep", + [ + (5.0, 10.0, True), # within tolerance, young + (5.0, 200.0, False), # within tolerance, too old (max_age cancel) + (1.0, 10.0, False), # outside tolerance, young (drift cancel) + (0.0, 10.0, False), # zero tolerance with non-zero drift + ], +) +def test_refresh_decision_table(monkeypatch, tolerance_bp, age_seconds, expect_keep) -> None: + """Compact truth table verifying the keep/cancel decision.""" + from strategies import mm_order_tracker as tracker_mod + + clock = {"t": 1000.0} + monkeypatch.setattr(tracker_mod.time, "monotonic", lambda: clock["t"]) + + order_manager = MagicMock() + order_manager.bulk_cancel_orders.return_value = 0 + order_manager.get_open_orders = MagicMock(return_value=[{"oid": 1}]) + tracker = OrderTracker(order_manager, refresh_interval_seconds=30, max_open_orders=4) + + tracker.record_order("BTC", oid=1, side="B", price=100.0) + clock["t"] += age_seconds + + # Drift = 2bp (constant): ((100.02 - 100) / 100) * 1e4 = 2 + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": 100.02, "A": 101.0}, + tolerance_bp=tolerance_bp, + max_age_seconds=120.0, + ) + + if expect_keep: + assert tracker.get_order_count("BTC") == 1 + else: + assert tracker.get_order_count("BTC") == 0 diff --git a/tests/test_refresh_tolerance.py b/tests/test_refresh_tolerance.py new file mode 100644 index 0000000..5f1b841 --- /dev/null +++ b/tests/test_refresh_tolerance.py @@ -0,0 +1,285 @@ +"""Tests for the order refresh tolerance feature. + +When ``refresh_tolerance_bp`` is enabled the run loop should: + + 1. Compute the current ideal bid/ask via ``_compute_ideal_prices``. + 2. Call ``OrderTracker.refresh_orders_with_tolerance`` (not the legacy + ``cancel_stale_orders``) so within-tolerance orders are kept. + 3. Skip placing a new quote on a side that already has a kept order + (``get_open_sides`` gating in ``_place_orders``). + +The default of ``refresh_tolerance_bp == 0`` must preserve the legacy +age-only behaviour (full backward compatibility). +""" + +from collections import defaultdict +from unittest.mock import MagicMock, patch + +from strategies.market_making_strategy import MarketMakingStrategy + + +def _make_strategy(refresh_tolerance_bp=0.0, refresh_max_age_seconds=120.0): + """Build a minimal MarketMakingStrategy bypassing __init__.""" + with patch.object(MarketMakingStrategy, '__init__', lambda self, *a, **k: None): + s = MarketMakingStrategy.__new__(MarketMakingStrategy) + s.spread_bps = 10 + s.order_size_usd = 100 + s.max_open_orders = 4 + s.max_positions = 10 + s.maker_only = True + s.account_cap_pct = 0.25 + s.bbo_mode = False + s.bbo_offset_bps = 0.0 + s.inventory_skew_bps = 0 + s.imbalance_threshold = 0.0 + s.loss_streak_limit = 0 + s.loss_streak_cooldown = 300 + s._loss_streaks = defaultdict(int) + s._coin_cooldown_until = {} + s._quiet_hours = set() + s._coin_offset_overrides = {} + s._coin_spread_overrides = {} + s._coin_size_overrides = {} + s._quiet_spread_multiplier = 0.0 + s._spread_schedule = {} + s._dynamic_offset_enabled = False + s._adverse_tracker = None + s._was_quiet = False + s._was_drain = False + s._drain_flag_file = '' + s.vol_adjust_enabled = False + s.vol_adjust_multiplier = 2.0 + s.vol_lookback = 30 + s._recent_mids = {} + s._microprice_enabled = False + s._microprice_multiplier = 0.0 + s._microprice_max_skew_bps = 0.0 + 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.refresh_tolerance_bp = refresh_tolerance_bp + s.refresh_max_age_seconds = refresh_max_age_seconds + + om = MagicMock() + md = MagicMock() + md.get_sz_decimals.return_value = 0 + md.price_rounding_params.return_value = (4, True) + s.order_manager = om + s.market_data = md + + tracker = MagicMock() + tracker.get_order_count.return_value = 0 + tracker.get_open_sides.return_value = set() + s._tracker = tracker + + closer = MagicMock() + closer.tracked_coins = set() + s._closer = closer + + return s, om, md, tracker + + +def _market_data(mid=100.0, bid=99.99, ask=100.01): + """Build a market_data mock with a stable BBO.""" + md = MagicMock() + md.mid_price = mid + md.bid = bid + md.ask = ask + md.book_imbalance = 0.0 + return md + + +class TestComputeIdealPrices: + """``_compute_ideal_prices`` must mirror ``_place_orders`` price logic.""" + + def test_returns_none_when_no_market_data(self): + s, _om, md, _ = _make_strategy() + md.get_market_data.return_value = None + + assert s._compute_ideal_prices("BTC") is None + + def test_returns_none_when_mid_zero(self): + s, _om, md, _ = _make_strategy() + market_data = _market_data(mid=0.0, bid=0.0, ask=0.0) + md.get_market_data.return_value = market_data + + assert s._compute_ideal_prices("BTC") is None + + def test_spread_mode_symmetric_around_mid(self): + s, _om, md, _ = _make_strategy() + s.spread_bps = 10 # 10bp = 0.1% + market_data = _market_data(mid=100.0, bid=99.99, ask=100.01) + md.get_market_data.return_value = market_data + + result = s._compute_ideal_prices("BTC") + assert result is not None + buy, sell = result + # 10bp around 100.0 = 99.9 / 100.1 + assert abs(buy - 99.9) < 1e-6 + assert abs(sell - 100.1) < 1e-6 + + def test_bbo_mode_at_best_bid_ask(self): + s, _om, md, _ = _make_strategy() + s.bbo_mode = True + s.bbo_offset_bps = 0.0 + market_data = _market_data(mid=100.0, bid=99.95, ask=100.05) + md.get_market_data.return_value = market_data + + # Override offset to 0 by stubbing _get_coin_offset + s._get_coin_offset = lambda coin: 0.0 + s._calculate_microprice_offsets = lambda coin, off: (off, off) + s._calculate_inventory_skew = lambda coin, mid: 0.0 + + result = s._compute_ideal_prices("BTC") + assert result is not None + buy, sell = result + assert abs(buy - 99.95) < 1e-6 + assert abs(sell - 100.05) < 1e-6 + + +class TestRunLoopTolerancePath: + """Verify the run-loop dispatches to the correct cancel method.""" + + def test_disabled_uses_legacy_cancel_stale_orders(self): + """``refresh_tolerance_bp == 0`` -> ``cancel_stale_orders`` is invoked.""" + s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=0.0) + market_data = _market_data() + md.get_market_data.return_value = market_data + + # Simulate the run-loop dispatch directly (without the surrounding + # boilerplate of MarketMakingStrategy.run): tolerance is 0, so the + # legacy method should be chosen. + if s.refresh_tolerance_bp > 0: + ideal = s._compute_ideal_prices("BTC") + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": ideal[0], "A": ideal[1]}, + tolerance_bp=s.refresh_tolerance_bp, + max_age_seconds=s.refresh_max_age_seconds, + close_oid=None, + ) + else: + tracker.cancel_stale_orders("BTC", close_oid=None) + + tracker.cancel_stale_orders.assert_called_once_with("BTC", close_oid=None) + tracker.refresh_orders_with_tolerance.assert_not_called() + + def test_enabled_uses_refresh_with_tolerance(self): + """``refresh_tolerance_bp > 0`` -> ``refresh_orders_with_tolerance`` is invoked.""" + s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=2.0) + market_data = _market_data(mid=100.0, bid=99.99, ask=100.01) + md.get_market_data.return_value = market_data + + if s.refresh_tolerance_bp > 0: + ideal = s._compute_ideal_prices("BTC") + tracker.refresh_orders_with_tolerance( + "BTC", + ideal_prices={"B": ideal[0], "A": ideal[1]}, + tolerance_bp=s.refresh_tolerance_bp, + max_age_seconds=s.refresh_max_age_seconds, + close_oid=None, + ) + else: + tracker.cancel_stale_orders("BTC", close_oid=None) + + tracker.refresh_orders_with_tolerance.assert_called_once() + call_kwargs = tracker.refresh_orders_with_tolerance.call_args.kwargs + assert call_kwargs["tolerance_bp"] == 2.0 + assert call_kwargs["max_age_seconds"] == 120.0 + assert "B" in call_kwargs["ideal_prices"] + assert "A" in call_kwargs["ideal_prices"] + tracker.cancel_stale_orders.assert_not_called() + + +class TestPlaceOrdersOpenSidesGating: + """``_place_orders`` skips a side that already has a kept tracked order.""" + + def _stub_for_place_orders(self, s): + """Common stubs needed by ``_place_orders``.""" + s._calculate_inventory_skew = lambda coin, mid: 0.0 + s._calculate_microprice_offsets = lambda coin, off: (off, off) + s._get_coin_offset = lambda coin: 0.0 + s._get_coin_spread = lambda coin: s.spread_bps + s._get_hourly_spread_multiplier = lambda: 1.0 + s.calculate_position_size = lambda coin, signal: 1.0 + s._record_mid_price = lambda coin, mid: None + + def test_places_both_sides_when_open_sides_empty(self): + s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=2.0) + self._stub_for_place_orders(s) + market_data = _market_data(mid=100.0, bid=99.99, ask=100.01) + md.get_market_data.return_value = market_data + md.round_size.return_value = 1.0 + tracker.get_open_sides.return_value = set() + s.order_manager.bulk_place_orders.return_value = [] + + s._place_orders("BTC") + + # 2 sides placed -> bulk_place_orders called with 2 orders. + s.order_manager.bulk_place_orders.assert_called_once() + placed_orders = s.order_manager.bulk_place_orders.call_args.args[0] + assert len(placed_orders) == 2 + + def test_skips_buy_when_buy_side_already_open(self): + from order_manager import OrderSide + + s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=2.0) + self._stub_for_place_orders(s) + market_data = _market_data(mid=100.0, bid=99.99, ask=100.01) + md.get_market_data.return_value = market_data + md.round_size.return_value = 1.0 + # Pretend a buy is still open from the previous cycle (kept by tolerance). + # The tracker stores the production string (OrderSide.BUY.value). + tracker.get_open_sides.return_value = {OrderSide.BUY.value} + s.order_manager.bulk_place_orders.return_value = [] + + s._place_orders("BTC") + + s.order_manager.bulk_place_orders.assert_called_once() + placed_orders = s.order_manager.bulk_place_orders.call_args.args[0] + assert len(placed_orders) == 1 + assert placed_orders[0].side == OrderSide.SELL + + def test_skips_sell_when_sell_side_already_open(self): + from order_manager import OrderSide + + s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=2.0) + self._stub_for_place_orders(s) + market_data = _market_data(mid=100.0, bid=99.99, ask=100.01) + md.get_market_data.return_value = market_data + md.round_size.return_value = 1.0 + tracker.get_open_sides.return_value = {OrderSide.SELL.value} + s.order_manager.bulk_place_orders.return_value = [] + + s._place_orders("BTC") + + placed_orders = s.order_manager.bulk_place_orders.call_args.args[0] + assert len(placed_orders) == 1 + assert placed_orders[0].side == OrderSide.BUY + + def test_open_sides_ignored_when_tolerance_disabled(self): + """When tolerance is 0 the gating is suppressed (legacy behaviour).""" + from order_manager import OrderSide + + s, _om, md, tracker = _make_strategy(refresh_tolerance_bp=0.0) + self._stub_for_place_orders(s) + market_data = _market_data(mid=100.0, bid=99.99, ask=100.01) + md.get_market_data.return_value = market_data + md.round_size.return_value = 1.0 + # Even if tracker reports a kept side, the legacy path should not + # consult get_open_sides at all. + tracker.get_open_sides.return_value = {OrderSide.BUY.value} + s.order_manager.bulk_place_orders.return_value = [] + + s._place_orders("BTC") + + placed_orders = s.order_manager.bulk_place_orders.call_args.args[0] + # Both sides placed: same as legacy behaviour (matches max_open_orders gating only). + assert len(placed_orders) == 2 diff --git a/validation/strategy_validator.py b/validation/strategy_validator.py index 7a1e722..aa066ad 100644 --- a/validation/strategy_validator.py +++ b/validation/strategy_validator.py @@ -227,6 +227,16 @@ def _validate_market_making(config: Dict) -> List[str]: errors += _positive_int('max_open_orders', config['max_open_orders']) if 'refresh_interval_seconds' in config: errors += _positive('refresh_interval_seconds', config['refresh_interval_seconds']) + if 'refresh_tolerance_bp' in config: + val = config['refresh_tolerance_bp'] + if not isinstance(val, (int, float)): + errors.append(f"refresh_tolerance_bp: expected number, got {type(val).__name__}") + elif val < 0: + errors.append(f"refresh_tolerance_bp: must be >= 0, got {val}") + if 'refresh_max_age_seconds' in config: + val = config['refresh_max_age_seconds'] + if val is not None: + errors += _positive('refresh_max_age_seconds', val) if 'max_position_age_seconds' in config: errors += _positive('max_position_age_seconds', config['max_position_age_seconds']) if 'taker_fallback_age_seconds' in config: