Coverage for adrs/oms/ops/order_placement_manager.py: 65%

353 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 05:07 +0800

1import asyncio 

2import logging 

3 

4from dataclasses import dataclass 

5from decimal import Decimal 

6from datetime import datetime, timezone, timedelta 

7from typing import Literal, Dict, List 

8 

9 

10from adrs.oms.config import ConfigManager 

11from adrs.oms.ops.order_executer import ( 

12 CancelFatal, 

13 CancelMultiResult, 

14 OrderExecutor, 

15 MAX_CONCURRENT_ORDER_OPS, 

16) 

17from adrs.oms.ops.order_utils import OrderUtils 

18from adrs.oms.position import PositionManager 

19from adrs.oms.price_feed import PriceFeed 

20from adrs.oms.ops.order_pool import ( 

21 BacklogDetails, 

22 CancelBacklogs, 

23 ExpiredBacklogs, 

24 OrderBacklogs, 

25 OrderDetails, 

26 OrderPoolHandler, 

27) 

28 

29from cybotrade import Symbol 

30from cybotrade.io import Event, EventType 

31from cybotrade.models import OrderSide, OrderUpdate, OrderStatus 

32 

33from adrs.oms.rate_limit.rate_limiter import RateLimiter 

34from adrs.oms.rate_limit.exchange_limit_profiles import Endpoints 

35from adrs.oms.rate_limit.error_policy import ExchangeErrorPolicy 

36 

37logger = logging.getLogger(__name__) 

38 

39# on_exchange_event below matches on EventType.PositionUpdate, which does not 

40# exist in cybotrade 2.1.0. `match` value patterns are attribute lookups 

41# evaluated in arm order, so on 2.1.0 the PositionUpdate arm itself raises 

42# AttributeError *while matching* -- not while handling -- and that exception 

43# propagates out of on_exchange_event into cybotrade's _consume_stream, which 

44# catches it, logs one generic warning, and moves on. Every ACCOUNT_UPDATE is 

45# then silently dropped: no exception surfaces anywhere near the OMS, no 

46# metric moves, and with the incremental fill writer removed there is nothing 

47# left to notice on its own. Fail loudly here instead, at import time, so a 

48# version mismatch is a startup crash with a clear cause rather than a single 

49# swallowed log line followed by silent over-placement. 

50if not hasattr(EventType, "PositionUpdate"): 

51 raise RuntimeError( 

52 "cybotrade>=2.2.0 is required: EventType.PositionUpdate is missing. " 

53 "This OMS drives PositionManager.exchange from the ACCOUNT_UPDATE " 

54 "stream (see PositionManager.apply_stream_positions) and no longer " 

55 "reconciles positions incrementally from fills, so a build without " 

56 "this event silently stops updating positions from the stream." 

57 ) 

58 

59# Caps the per-tick burst below the ~8/s order/cancel pool limit 

60MAX_CONCURRENT_BACKLOG_RETRIES = 3 

61 

62# Consecutive on_order_placement ticks a symbol can fail to cancel its 

63# opposite-side orders before the circuit breaker logs loudly. Every such 

64# tick already skips that symbol's replacement (see on_order_placement); this 

65# only escalates a persistent problem (e.g. sustained rate limiting) from a 

66# per-tick warning to a standing alert, instead of it silently piling into 

67# the retry backlog tick after tick. 

68CANCEL_FAILURE_CIRCUIT_BREAKER_THRESHOLD = 3 

69 

70 

71class OrderPlacementManager: 

72 def __init__( 

73 self, 

74 position: PositionManager, 

75 config: ConfigManager, 

76 rate_limiter: RateLimiter, 

77 error_policy: ExchangeErrorPolicy, 

78 executor_cls: type[OrderExecutor] = OrderExecutor, 

79 price_feed: PriceFeed | None = None, 

80 ) -> None: 

81 self.position = position 

82 self.config_manager = config 

83 self.price_feed = price_feed 

84 self.order_pools = OrderPoolHandler( 

85 exchange=config.exchange, 

86 config=self.config_manager, 

87 rate_limiter=rate_limiter, 

88 ) 

89 # Forwarded only when a feed exists: executor_cls is a documented subclass 

90 # extension point (oms.py), and an override that predates this parameter must 

91 # keep working unchanged when no feed is configured. 

92 executor_kwargs = {} if price_feed is None else {"price_feed": price_feed} 

93 self.executor = executor_cls( 

94 config_manager=self.config_manager, 

95 order_pools=self.order_pools, 

96 rate_limiter=rate_limiter, 

97 error_policy=error_policy, 

98 **executor_kwargs, 

99 ) 

100 self.rate_limiter = rate_limiter 

101 # Consecutive on_order_placement ticks in a row where this symbol's 

102 # cancel_multi_limit_order had at least one failure. Reset on any 

103 # tick where every chosen cancel succeeds. 

104 self._consecutive_cancel_failures: Dict[Symbol, int] = {} 

105 

106 def _note_cancel_outcome(self, symbol: Symbol, cancel_result: CancelMultiResult): 

107 if cancel_result.failed_count == 0: 

108 self._consecutive_cancel_failures.pop(symbol, None) 

109 return 

110 streak = self._consecutive_cancel_failures.get(symbol, 0) + 1 

111 self._consecutive_cancel_failures[symbol] = streak 

112 if streak >= CANCEL_FAILURE_CIRCUIT_BREAKER_THRESHOLD: 

113 logger.error( 

114 f"[CIRCUIT_BREAKER] {symbol} has failed to cancel opposite-side " 

115 f"orders for {streak} consecutive ticks ({cancel_result.failed_count} " 

116 f"failure(s) this tick) - placement has been skipped every one of " 

117 f"those ticks, but this many in a row means the problem is not " 

118 f"self-resolving. Check the rate limiter / exchange connectivity." 

119 ) 

120 

121 async def on_exchange_event(self, event: Event): 

122 """ 

123 Handles various events sent by exchange 

124 """ 

125 match event.event_type: 

126 case EventType.Authenticated | EventType.Subscribed: 

127 logger.info(f"[ON_EVENT] '{event.event_type}': {event.data}") 

128 # cybotrade emits Authenticated on every connect, including 

129 # reconnects -- read it as "feed restarted". Everything that 

130 # happened during the gap (ORDER_TRADE_UPDATE as well as 

131 # ACCOUNT_UPDATE) is lost with nothing else to notice, so the 

132 # REST anchor must not still look fresh: force the next 

133 # delta_calculation to read. 

134 self.position.invalidate_exchange_anchor() 

135 case EventType.OrderUpdate: 

136 logger.debug(f"[ON_EVENT] '{event.event_type}': {event.data}") 

137 await self.on_order_update(event.data) 

138 case EventType.Error: 

139 logger.error(f"[ON_EVENT] '{event.event_type}': {event.data}") 

140 case EventType.PositionUpdate: 

141 logger.debug(f"[ON_EVENT] '{event.event_type}': {event.data}") 

142 self.position.apply_stream_positions(event.data) 

143 case _: 

144 logger.warning(f"[ON_EVENT] '{event.event_type}': {event.data}") 

145 

146 async def validate_oms_state(self, update: OrderUpdate) -> bool: 

147 """ 

148 Return True when the update can be applied to local state. 

149 

150 An unknown id only warrants a full open-orders resync when the order 

151 is still live on the exchange; in-flight placements and terminal 

152 events are handled with at most a position refresh. 

153 """ 

154 async with self.order_pools.get_order_pool() as order_pool: 

155 if update.client_order_id in order_pool: 

156 return True 

157 

158 is_terminal = update.status in ( 

159 OrderStatus.FILLED, 

160 OrderStatus.PARTIALLY_FILLED_CANCELLED, 

161 ) 

162 

163 if self.order_pools.is_pending_insert(update.client_order_id): 

164 if is_terminal: 

165 await self.position.update_exchange() 

166 logger.info( 

167 f"[VALIDATE] {update.client_order_id} in flight, WS beat the REST response" 

168 ) 

169 return False 

170 

171 if is_terminal: 

172 await self.position.update_exchange() 

173 logger.info( 

174 f"[VALIDATE] Terminal update for unknown order {update.client_order_id}, refreshed positions" 

175 ) 

176 return False 

177 

178 logger.warning( 

179 f"[VALIDATE] Live order {update.client_order_id} missing from local pool, resyncing" 

180 ) 

181 snapshot = await self.order_pools.fetch_open_orders_snapshot() 

182 await self.position.update_exchange() 

183 self.position.update_pending(snapshot) 

184 await self.order_pools.resync_order_pool(snapshot) 

185 return False 

186 

187 def update_positions(self, update: OrderUpdate): 

188 # filled_size is cumulative. Apply only the new slice and store the 

189 # cumulative total (not the slice) as the next baseline, otherwise the 

190 # baseline drifts and fills over-count from the third update onward. 

191 # 

192 # Only `pending` is maintained here now. The exchange position is owned 

193 # absolutely by ACCOUNT_UPDATE (see PositionManager.apply_stream_positions): 

194 # this used to also do `exchange += asset_filled`, which drifted for the 

195 # same reason the baseline does and relied on REST polling to correct it. 

196 # `pending` keeps its increment because the open-orders snapshot corrects 

197 # it every placement tick. 

198 last_filled = self.order_pools.order_value_update.get( 

199 update.client_order_id, Decimal("0") 

200 ) 

201 increment = update.filled_size - last_filled 

202 self.order_pools.order_value_update[update.client_order_id] = update.filled_size 

203 asset_filled = increment if update.side == OrderSide.BUY else -increment 

204 self.position.pending[update.symbol].quantity -= asset_filled 

205 

206 async def on_order_update(self, update: OrderUpdate): 

207 """ 

208 Handles Event OrderUpdate where position update and calculation would be done 

209 """ 

210 symbol = str(update.symbol) 

211 

212 if update.status == OrderStatus.CREATED: 

213 logger.info( 

214 f"[ORDER_UPDATE|CREATED] Limit {symbol} order: {update.client_order_id} is {update.status}" 

215 ) 

216 # Update local store in case rest api hits rate limits 

217 self.position.pending[update.symbol].quantity += ( 

218 update.size if update.side == OrderSide.BUY else -update.size 

219 ) 

220 return 

221 

222 if update.status == OrderStatus.CANCELLED: 

223 # Pop covers externally cancelled orders (manual UI, exchange purge) 

224 # which would otherwise stay in the pool and fail cancel forever; 

225 # already removed by our own cancel path is fine, pop is idempotent 

226 async with self.order_pools.get_order_pool() as order_pool: 

227 order_pool.pop(update.client_order_id, None) 

228 # Remove unfulffilled amount from pending for to remake order in next delta calculation 

229 self.position.pending[update.symbol].quantity -= ( 

230 update.remain_size 

231 if update.side == OrderSide.BUY 

232 else -update.remain_size 

233 ) 

234 logger.info( 

235 f"[ORDER_UPDATE|CANCELLED] Removed {update.client_order_id} from order_pool due to order {update.status}" 

236 ) 

237 return 

238 

239 # Majority of cases if when price moved during order placement invalidating post-only orders 

240 # Hence retry with bbo 

241 if update.status == OrderStatus.REJECTED: 

242 logger.info( 

243 f"[ORDER_UPDATE|REJECTED] {update.client_order_id} has been rejected due to order {update.status}" 

244 ) 

245 # TEST WILL NEED TO WATCH OUT THE FLOW WHEN THIS ACTUALLY TRIGGERS 

246 async with self.order_pools.get_order_pool() as order_pool: 

247 # pop: WS REJECTED can beat the REST response that inserts the 

248 # pool entry, del would raise and kill the event handler 

249 rejected_order = order_pool.pop(update.client_order_id, None) 

250 if rejected_order is not None: 

251 # Mirror CANCELLED accounting: CREATED added this qty to pending 

252 # and the reprice below adds its own on its CREATED, without 

253 # this the rejected order's qty leaks in pending until resync 

254 self.position.pending[update.symbol].quantity -= ( 

255 update.remain_size 

256 if update.side == OrderSide.BUY 

257 else -update.remain_size 

258 ) 

259 result = await self.executor.reprice_at_bbo( 

260 symbol=update.symbol, 

261 qty=update.remain_size, 

262 side=update.side, 

263 replace_best_bid_ask_time=datetime.now(timezone.utc) 

264 + timedelta( 

265 seconds=self.config_manager.config.replace_best_bid_ask_time 

266 ), 

267 package_id=self.executor.package_id, 

268 ) 

269 if result: 

270 async with self.order_pools.get_order_backlog() as order_backlog: 

271 self.order_pools.dedup_append(order_backlog, result) 

272 return 

273 

274 # Create and cancel don't need as order might not be in order pool yet 

275 if not await self.validate_oms_state(update=update): 

276 # validate logged the specific cause and refreshed what it needed 

277 return 

278 

279 is_terminal = ( 

280 update.status == OrderStatus.FILLED 

281 or update.status == OrderStatus.PARTIALLY_FILLED_CANCELLED 

282 ) 

283 

284 # Update Order Details. validate_oms_state released the pool lock, so the 

285 # entry may have been popped concurrently (e.g. a FILLED beat us) — bail 

286 # if it's gone. All ops below are synchronous, so the lock stays a leaf. 

287 async with self.order_pools.get_order_pool() as order_pool: 

288 entry = order_pool.get(update.client_order_id) 

289 if entry is None: 

290 return 

291 entry.remain_size = update.remain_size 

292 if is_terminal: 

293 order_pool.pop(update.client_order_id, None) 

294 pool_size = len(order_pool) 

295 

296 if update.status == OrderStatus.PARTIALLY_FILLED: 

297 logger.info( 

298 f"[ORDER_UPDATE|PARTIALLY_FILLED] {update.client_order_id} has filled {update.filled_size}, remain {update.remain_size}" 

299 ) 

300 self.update_positions(update=update) 

301 return 

302 

303 if is_terminal: 

304 self.update_positions(update=update) 

305 if update.status == OrderStatus.PARTIALLY_FILLED_CANCELLED: 

306 # Unfilled remainder was added to pending at CREATED but the 

307 # cancel leaves it working forever; mirror the CANCELLED branch 

308 self.position.pending[update.symbol].quantity -= ( 

309 update.remain_size 

310 if update.side == OrderSide.BUY 

311 else -update.remain_size 

312 ) 

313 self.order_pools.order_value_update.pop(update.client_order_id, None) 

314 logger.info( 

315 f"[ORDER_UPDATE|FILLED] Removed {update.client_order_id} from order_pool due to order {update.status}, current order_pool size: {pool_size}" 

316 ) 

317 return 

318 

319 def on_position_check(self): 

320 desired = self.position.desired 

321 pending = self.position.pending 

322 exchange = self.position.exchange 

323 logger.info("[OMS STATUS CHECK]") 

324 for position in ( 

325 (desired, "DESIRED"), 

326 (pending, "PENDING"), 

327 (exchange, "EXCHANGE"), 

328 ): 

329 logger.info( 

330 f"[{position[1]} POSITION] {' '.join([(parts[0] if (parts := symbol.split()) else 'Unknown') + ': ' + str(position.quantity) for symbol, position in position[0].items()])}" 

331 ) 

332 logger.info(f"[RATE LIMITER STATS] {self.rate_limiter}") 

333 

334 async def on_order_placement(self): 

335 """ 

336 Periodic rebalance execution logic. 

337 

338 Rebalance logic: 

339 - delta > 0 => buy, delta < 0 => sell 

340 - pending tracks already-submitted orders (same sign = same direction) 

341 

342 Rules: 

343 - If delta and pending have opposite signs → cancel pending 

344 - Else (same direction) → place extra orders 

345 

346 TLDR: Same direction → add orders, Opposite → cancel 

347 """ 

348 self.executor.update_package_id() 

349 self.on_position_check() 

350 snapshot = await self.order_pools.fetch_open_orders_snapshot() 

351 deltas = await self.position.delta_calculation(snapshot) 

352 queued: dict[Symbol, Decimal] = {} 

353 async with self.order_pools.get_order_backlog() as order_backlog: 

354 for backlog in order_backlog: 

355 if not isinstance(backlog, OrderBacklogs): 

356 continue 

357 symbol = Symbol(backlog.symbol) 

358 signed = backlog.qty if backlog.side == OrderSide.BUY else -backlog.qty 

359 queued[symbol] = queued.get(symbol, Decimal("0")) + signed 

360 

361 for symbol, queued_qty in queued.items(): 

362 if symbol not in deltas: 

363 continue 

364 adjusted = deltas[symbol] - queued_qty 

365 if deltas[symbol] >= 0: 

366 deltas[symbol] = max(Decimal("0"), adjusted) 

367 else: 

368 deltas[symbol] = min(Decimal("0"), adjusted) 

369 

370 if all(delta == Decimal("0") for delta in deltas.values()): 

371 logger.info("Delta between desired and pending/exchange is currently zero") 

372 return 

373 

374 logger.info(f"[ON_ORDER_PLACEMENT] Deltas {deltas}") 

375 

376 try: 

377 await self.config_manager.update_symbol_info(self.rate_limiter) 

378 except Exception as e: 

379 logger.warning(f"update symbol info failed due to {e}") 

380 

381 for symbol, delta in deltas.items(): 

382 if delta == 0: 

383 continue 

384 

385 # To determine whether the delta is big enough to proceed 

386 residual_threshold = self.config_manager.symbol_infos[symbol].min_limit_qty 

387 if abs(delta) < residual_threshold: 

388 logger.info( 

389 f"{symbol}'s delta {delta} is lower than provided threshold {residual_threshold}, it is ignored" 

390 ) 

391 continue 

392 

393 # To double check just in case exchange didn't properly update min limit 

394 notional_threshold = self.config_manager.symbol_infos[symbol].min_notional 

395 current_price = await self.executor.get_current_price(symbol=symbol) 

396 if current_price is None: 

397 logger.warning( 

398 f"{symbol}'s current price couldn't be fetched, skipping this tick" 

399 ) 

400 continue 

401 notional_value = abs(delta) * current_price 

402 if notional_value < notional_threshold: 

403 logger.info( 

404 f"{symbol}'s delta {delta} is lower than provided notional threshold {notional_threshold}, it is ignored" 

405 ) 

406 continue 

407 

408 pending = self.position.pending[symbol].quantity 

409 direction = pending * delta if pending != 0 else delta 

410 

411 # if there is orders on the opposite side cancel first 

412 if direction < 0 and pending != 0: 

413 open_orders = snapshot.orders.get(symbol) 

414 if open_orders is None: 

415 # Fetch failed for this symbol; delta retries next tick 

416 logger.warning( 

417 f"{symbol} missing from open-orders snapshot, skipping reversal this tick" 

418 ) 

419 continue 

420 cancel_result = await self.executor.cancel_multi_limit_order( 

421 symbol=symbol, target=delta, open_orders=open_orders 

422 ) 

423 self._note_cancel_outcome(symbol, cancel_result) 

424 if cancel_result.failed_count: 

425 # Some opposite-side orders are still live/unconfirmed; 

426 # placing a same-tick replacement now would size it as if 

427 # they were gone. Skip - the failed cancels are already 

428 # queued for retry, and the next tick's fresh delta 

429 # (informed by whatever actually happens to them) decides. 

430 logger.warning( 

431 f"[ON_ORDER_PLACEMENT] {cancel_result.failed_count} cancel(s) " 

432 f"failed for {symbol}; skipping this tick's replacement order" 

433 ) 

434 continue 

435 delta = cancel_result.remainder 

436 

437 # place order based on delta or remander 

438 await self.executor.place_multiple_limit_order( 

439 symbol=symbol, 

440 quantity=delta, 

441 ) 

442 self.on_position_check() 

443 

444 async def on_retry_backlog(self): 

445 """ 

446 To reattempt all failed orders/cancels 

447 """ 

448 if self.rate_limiter.retry_after >= self.rate_limiter.get_synced_time_ms(): 

449 logger.info("[ON_RETRY_BACKLOG] Skipping tick, rate limiter cooling down") 

450 return 

451 

452 now = datetime.now(timezone.utc) 

453 

454 # Snapshot due items under the lock; run the I/O with it released 

455 async with self.order_pools.get_order_backlog() as order_backlog: 

456 if len(order_backlog) == 0: 

457 return 

458 logger.info(f"[ON_RETRY_BACKLOG] Current backlog size {len(order_backlog)}") 

459 due: list[BacklogDetails] = [] 

460 for backlog in list(order_backlog): 

461 if backlog.next_retry_at is not None and backlog.next_retry_at > now: 

462 continue 

463 if ( 

464 backlog.total_retries 

465 >= self.config_manager.config.max_retries_allowed 

466 ): 

467 order_backlog.remove(backlog) 

468 # Giving up on a CANCEL is not the same as giving up on a 

469 # placement. If the order is still in the pool it may still be 

470 # resting on the exchange, against the strategy's intent, and 

471 # nothing will try to remove it again — so it is an error, not 

472 # the routine warning an abandoned placement gets. Rate-limit 

473 # refusals burn retries without a request ever reaching the 

474 # exchange, so this path is reached fastest exactly when the 

475 # cancel pool is saturated. _retry_one already treats a 

476 # departed order as done, so `still_ours` is what separates 

477 # "nothing left to cancel" from "we stopped trying". 

478 still_ours = ( 

479 isinstance(backlog, CancelBacklogs | ExpiredBacklogs) 

480 and backlog.client_order_id in self.order_pools.order_pool 

481 ) 

482 if still_ours: 

483 logger.error( 

484 f"[ON_RETRY_BACKLOG] Gave up cancelling " 

485 f"{backlog.client_order_id} on {backlog.symbol} after " 

486 f"{backlog.total_retries} attempts; it is still in the " 

487 f"order pool and may be resting on the exchange. " 

488 f"Backlog size {len(self.order_pools.order_backlog)}" 

489 ) 

490 else: 

491 logger.warning( 

492 f"[ON_RETRY_BACKLOG] Removed backlog {backlog} exceeded max retries, Current backlog size {len(self.order_pools.order_backlog)}" 

493 ) 

494 continue 

495 due.append(backlog) 

496 

497 if not due: 

498 return 

499 

500 sem = asyncio.Semaphore(MAX_CONCURRENT_BACKLOG_RETRIES) 

501 

502 async def _bounded(backlog: BacklogDetails): 

503 async with sem: 

504 return await self._retry_one(backlog, now) 

505 

506 outcomes = await asyncio.gather( 

507 *[_bounded(backlog) for backlog in due], 

508 return_exceptions=True, 

509 ) 

510 

511 # Re-acquire only to apply list mutations 

512 async with self.order_pools.get_order_backlog() as order_backlog: 

513 for backlog, outcome in zip(due, outcomes): 

514 if isinstance(outcome, BaseException): 

515 logger.error( 

516 f"[ON_RETRY_BACKLOG] Retry crashed for {backlog} due to {outcome}" 

517 ) 

518 backlog.total_retries += 1 

519 backlog.next_retry_at = now + timedelta( 

520 seconds=min(2 * backlog.total_retries, 30) 

521 ) 

522 continue 

523 

524 should_remove, replacement = outcome 

525 if should_remove and backlog in order_backlog: 

526 order_backlog.remove(backlog) 

527 if replacement is not None: 

528 self.order_pools.dedup_append(order_backlog, replacement) 

529 

530 async def _retry_one(self, backlog: BacklogDetails, now: datetime): 

531 """Retry one item. Returns (should_remove, replacement_order).""" 

532 if isinstance(backlog, OrderBacklogs): 

533 symbol = Symbol(backlog.symbol) 

534 result = await self.executor.place_single_limit_order( 

535 symbol=symbol, 

536 offset=backlog.offset, 

537 qty=backlog.qty, 

538 side=backlog.side, 

539 symbol_info=self.config_manager.symbol_infos[symbol], 

540 package_id=backlog.package_id, 

541 initial_price=backlog.initial_price, 

542 initial_time=backlog.initial_time, 

543 client_order_id=backlog.client_order_id, 

544 ) 

545 elif isinstance(backlog, CancelBacklogs | ExpiredBacklogs): 

546 async with self.order_pools.get_order_pool() as order_pool: 

547 still_ours = backlog.client_order_id in order_pool 

548 if not still_ours: 

549 logger.info( 

550 f"[ON_RETRY_BACKLOG] {backlog.client_order_id} already left the pool, dropping backlog without replacement" 

551 ) 

552 return (True, None) 

553 result = await self.executor.cancel_single_order( 

554 symbol=Symbol(backlog.symbol), 

555 client_order_id=backlog.client_order_id, 

556 ) 

557 else: 

558 raise Exception("Unknown Backlog Type") 

559 

560 if isinstance(result, CancelFatal): 

561 return (True, None) 

562 

563 # Failed: strike + backoff, keep 

564 if result is not None: 

565 backlog.total_retries += 1 

566 delay = min(2 * backlog.total_retries, 30) 

567 backlog.next_retry_at = now + timedelta(seconds=delay) 

568 return (False, None) 

569 

570 # Success: remove; expired cancel also re-places 

571 if isinstance(backlog, ExpiredBacklogs): 

572 return (True, await self._reprice_expired(backlog)) 

573 return (True, None) 

574 

575 async def _reprice_expired(self, backlog: ExpiredBacklogs): 

576 """Reconfirm remaining qty after cancel, then re-place. Returns replacement or None.""" 

577 qty = backlog.qty 

578 details = None 

579 try: 

580 async with self.rate_limiter.guard(endpoint=Endpoints.GET_ORDER_DETAILS): 

581 details = ( 

582 await self.config_manager.exchange.get_order_details_from_history( 

583 symbol=Symbol(backlog.symbol), 

584 client_order_id=backlog.client_order_id, 

585 ) 

586 ) 

587 except Exception as e: 

588 logger.warning( 

589 f"[ON_RETRY_BACKLOG] Could not reconfirm remaining qty for {backlog.client_order_id} due to {e}, leaving requantification to the delta path" 

590 ) 

591 return None 

592 if details is not None: 

593 if details.status in ( 

594 OrderStatus.CREATED, 

595 OrderStatus.PARTIALLY_FILLED, 

596 ): 

597 logger.warning( 

598 f"[ON_RETRY_BACKLOG] {backlog.client_order_id} still shows open in history, skipping replacement" 

599 ) 

600 return None 

601 qty = details.remain_size 

602 if qty <= Decimal("0"): 

603 logger.info( 

604 f"[ON_RETRY_BACKLOG] {backlog.client_order_id} fully filled during backlog wait, nothing to replace" 

605 ) 

606 return None 

607 # Now try placing order will turn expire into order backlog if fails 

608 if backlog.is_bbo: 

609 return await self.executor.reprice_at_bbo( 

610 symbol=Symbol(backlog.symbol), 

611 qty=qty, 

612 side=backlog.side, 

613 replace_best_bid_ask_time=backlog.replace_best_bid_ask_time, 

614 package_id=backlog.package_id, 

615 initial_price=backlog.initial_price, 

616 initial_time=backlog.initial_time, 

617 ) 

618 return await self.executor.reprice_at_mid( 

619 symbol=Symbol(backlog.symbol), 

620 qty=qty, 

621 side=backlog.side, 

622 replace_best_bid_ask_time=backlog.replace_best_bid_ask_time, 

623 package_id=backlog.package_id, 

624 initial_price=backlog.initial_price, 

625 initial_time=backlog.initial_time, 

626 ) 

627 

628 async def on_order_expiry_check(self): 

629 """ 

630 Remove orders that have exceeded the allowed lifetime. 

631 

632 Replaces them at Update Price (MID) or BBO depending on the trigger. 

633 """ 

634 

635 @dataclass 

636 class PendingExpiry: 

637 order: OrderDetails 

638 strategy: Literal["BBO", "MID"] 

639 

640 # Decide which orders expired (no delta_lock — read-only price discovery) 

641 async with self.order_pools.get_order_pool() as order_pool: 

642 orders_to_check = list(order_pool.values()) 

643 

644 if not orders_to_check: 

645 return 

646 

647 async with self.order_pools.get_order_backlog() as order_backlog: 

648 queued_ids = {b.client_order_id for b in order_backlog} 

649 if queued_ids: 

650 orders_to_check = [ 

651 o for o in orders_to_check if o.client_order_id not in queued_ids 

652 ] 

653 if not orders_to_check: 

654 return 

655 

656 pending_actions: dict[str, PendingExpiry] = {} 

657 current_time = datetime.now(timezone.utc) 

658 

659 get_depth_endpoint = Endpoints.GET_ORDERBOOK_SNAPSHOT 

660 current_prices: Dict[Symbol, List[Decimal]] = {} 

661 for order in orders_to_check: 

662 symbol = Symbol(order.symbol) 

663 

664 reason = None 

665 if order.replace_best_bid_ask_time <= current_time: 

666 reason = "BBO" 

667 elif order.max_replace_limit_order_time <= current_time: 

668 reason = "MID" 

669 

670 if reason: 

671 if symbol not in current_prices: 

672 try: 

673 order_book = await OrderUtils.get_order_book( 

674 exchange=self.executor.exchange, 

675 pair=symbol, 

676 need_log=False, 

677 rate_limiter=self.rate_limiter, 

678 endpoint=get_depth_endpoint, 

679 price_feed=self.price_feed, 

680 ) 

681 current_prices[symbol] = order_book 

682 except Exception as e: 

683 logger.warning( 

684 f"Current price for {order.symbol} couldn't be fetch due to {e}" 

685 ) 

686 order_book = current_prices.get(symbol) 

687 current_price = ( 

688 (order_book[0] if order.side == OrderSide.BUY else order_book[1]) 

689 if order_book 

690 else None 

691 ) 

692 if not current_price or current_price != order.price: 

693 # Entry stays in the pool until cancel succeeds: 

694 # cancel_single_order pops on success, and treats a failed 

695 # cancel for an already-vanished entry as done — popping here 

696 # would make every transient cancel failure look like success 

697 # and reprice a still-live order 

698 pending_actions[order.client_order_id] = PendingExpiry( 

699 order, reason 

700 ) 

701 

702 if not pending_actions: 

703 return 

704 

705 logger.info( 

706 f"[ON_ORDER_EXPIRY_CHECK] Processing {len(pending_actions)} expired orders" 

707 ) 

708 

709 # Cancel + replace under delta_lock (the in-flight window); collect 

710 # backlog locally so the backlog lock isn't held across the gathers 

711 backlog_to_add: list[BacklogDetails] = [] 

712 sem = asyncio.Semaphore(MAX_CONCURRENT_ORDER_OPS) 

713 

714 async def _bounded(coro): 

715 async with sem: 

716 return await coro 

717 

718 async with self.position.delta_lock: 

719 cancel_tasks = [ 

720 _bounded( 

721 self.executor.cancel_single_order( 

722 symbol=Symbol(action.order.symbol), 

723 client_order_id=action.order.client_order_id, 

724 ) 

725 ) 

726 for action in pending_actions.values() 

727 ] 

728 

729 cancel_results = await asyncio.gather(*cancel_tasks, return_exceptions=True) 

730 

731 replacement_tasks = [] 

732 

733 for action, result in zip(pending_actions.values(), cancel_results): 

734 if isinstance(result, BaseException): 

735 logger.error( 

736 f"[ON_ORDER_EXPIRY_CHECK] Critical Cancel Error: {result}" 

737 ) 

738 continue 

739 

740 if isinstance(result, CancelFatal): 

741 logger.error( 

742 f"[ON_ORDER_EXPIRY_CHECK] FATAL cancel for " 

743 f"{action.order.client_order_id}, skipping replacement" 

744 ) 

745 continue 

746 

747 if result is not None: 

748 expire_backlog = ExpiredBacklogs( 

749 side=action.order.side, 

750 qty=action.order.remain_size, 

751 is_bbo=(action.strategy == "BBO"), 

752 max_replace_limit_order_time=action.order.max_replace_limit_order_time, 

753 replace_best_bid_ask_time=action.order.replace_best_bid_ask_time, 

754 package_id=action.order.package_id, 

755 initial_price=action.order.initial_price, 

756 initial_time=action.order.initial_time, 

757 **result.model_dump(), 

758 ) 

759 backlog_to_add.append(expire_backlog) 

760 continue 

761 

762 if action.strategy == "BBO": 

763 logger.info( 

764 f"[ON_ORDER_EXPIRY_CHECK] Repricing {action.order.symbol} order at BBO with {action.order.remain_size}" 

765 ) 

766 new_task = self.executor.reprice_at_bbo( 

767 symbol=Symbol(action.order.symbol), 

768 qty=action.order.remain_size, 

769 side=action.order.side, 

770 replace_best_bid_ask_time=action.order.replace_best_bid_ask_time, 

771 package_id=action.order.package_id, 

772 initial_price=action.order.initial_price, 

773 initial_time=action.order.initial_time, 

774 ) 

775 else: 

776 logger.info( 

777 f"[ON_ORDER_EXPIRY_CHECK] Repricing {action.order.symbol} order at MID with {action.order.remain_size}" 

778 ) 

779 new_task = self.executor.reprice_at_mid( 

780 symbol=Symbol(action.order.symbol), 

781 qty=action.order.remain_size, 

782 side=action.order.side, 

783 replace_best_bid_ask_time=action.order.replace_best_bid_ask_time, 

784 package_id=action.order.package_id, 

785 initial_price=action.order.initial_price, 

786 initial_time=action.order.initial_time, 

787 ) 

788 replacement_tasks.append(_bounded(new_task)) 

789 

790 if replacement_tasks: 

791 logger.info( 

792 f"[ON_ORDER_EXPIRY_CHECK] Sending {len(replacement_tasks)} replacement orders. " 

793 f"({len(pending_actions) - len(replacement_tasks)} failed/skipped)" 

794 ) 

795 

796 replace_results = await asyncio.gather( 

797 *replacement_tasks, return_exceptions=True 

798 ) 

799 

800 for res in replace_results: 

801 if res is None: 

802 continue 

803 if isinstance(res, BaseException): 

804 logger.error( 

805 f"[ON_ORDER_EXPIRY_CHECK] Critical Replace Error: {res}" 

806 ) 

807 else: 

808 backlog_to_add.append(res) 

809 

810 if backlog_to_add: 

811 async with self.order_pools.get_order_backlog() as order_backlog: 

812 for item in backlog_to_add: 

813 self.order_pools.dedup_append(order_backlog, item)