Coverage for adrs/oms/oms.py: 84%
435 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 05:07 +0800
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 05:07 +0800
1import os
2import json
3import copy
4import time
5import asyncio
6import logging
7import signal
9from typing import Dict, Protocol
10from decimal import Decimal
11from pydantic import BaseModel
12from datetime import datetime, timezone, timedelta
14from aion import Scheduler, Trigger
15from nats_client import Msg
16from adrs.data import MetricStream, MetricBuilder
17from adrs.data.connector import DEFAULT_METRIC_NAMESPACE
18from adrs.subjects import portfolio_signal_subject, oms_command_subject
19from cybotrade import Symbol
20from cybotrade.io import ExchangeEvent
21from cybotrade.io.event import Event, EventType
22from cybotrade.models import Position, OrderSide, OrderStatus
24from adrs.oms.config import ConfigManager
25from adrs.oms.price_feed import PriceFeed
26from adrs.oms.ops.order_executer import OrderExecutor, MAX_CONCURRENT_ORDER_OPS
27from adrs.oms.ops.order_pool import CancelBacklogs
28from adrs.oms.position import PositionManager
29from adrs.oms.risk import RiskEngine
30from adrs.oms.ops.order_placement_manager import OrderPlacementManager
31from adrs.oms.rate_limit.rate_limiter import RateLimiter
32from adrs.oms.rate_limit.exchange_limit_profiles import Endpoints
33from adrs.oms.rate_limit.error_policy import ExchangeErrorPolicy
35logger = logging.getLogger(__name__)
37# A record whose order the exchange history still can't find after this long
38# is unrecoverable; keeping it would poll the shared budget forever
39AEGIS_NOT_FOUND_GIVE_UP = timedelta(hours=1)
41# A frame this much older than our synced clock means the event loop stalled or
42# our clock drifted. Reported rather than enforced: the staleness guard stays on
43# the monotonic clock, which is immune to both.
44PRICE_FEED_EVENT_TIME_DIVERGENCE_WARN_MS = 2_000
46# How often the divergence summary may be emitted. Detection stays per frame;
47# only the reporting is throttled.
48#
49# A stall delays every frame queued behind it, and bookTicker delivers hundreds
50# of frames a second, so logging per frame described a single event hundreds of
51# times: one 9-second startup stall produced 156 near-identical WARNINGs inside
52# one minute, burying the rest of the log. The aggregated line carries the count
53# and the worst value, which says more than any single occurrence did.
54PRICE_FEED_DIVERGENCE_LOG_INTERVAL_SEC = 60.0
56# How long _handle_shutdown waits before retrying cancels that failed on the way
57# out.
58#
59# This used to be 60 seconds, which is exactly the pods'
60# terminationGracePeriodSeconds (verified on both a Binance and a Bybit tenant).
61# The sleep therefore consumed the whole grace period and SIGKILL landed as the
62# retry began, so a rate-limited cancel was never actually retried and every
63# shutdown with one took the full minute. The retry was dead code.
64#
65# What it is really waiting for is a rate-limit pool to refill, and those recover
66# in about a second — Bybit's cancel pool is 8/s, Binance's weight window is one
67# minute but a cancel costs 1 of thousands. A few seconds is therefore ample, and
68# it leaves the rest of the grace period for the retry itself to complete.
69SHUTDOWN_CANCEL_RETRY_DELAY_SEC = 5.0
72class PortfolioSignal(BaseModel):
73 assets: Dict[str, Decimal]
74 timestamp: int
77class OMSEventHandler(Protocol):
78 """
79 Observer for key OMS state transitions. Pass an implementation to OMS.__init__
80 to receive structured callbacks instead of parsing log output.
82 All methods are synchronous — keep them fast (record to a queue, increment a
83 counter). The default (None) skips every call.
84 """
86 def on_signal_received(self, signal: "PortfolioSignal") -> None: ...
87 def on_desired_updated(self, symbol: "Symbol", quantity: "Decimal") -> None: ...
88 def on_signal_skipped(self, symbol: "Symbol", reason: str) -> None: ...
91def generate_cron(total_seconds: int):
92 if total_seconds < 1:
93 return "* * * * * *"
95 hours = total_seconds // 3600
96 minutes = (total_seconds % 3600) // 60
97 seconds = total_seconds % 60
99 if hours > 0:
100 return f"{seconds} {minutes} */{hours} * * *"
101 elif minutes > 0:
102 return f"{seconds} */{minutes} * * * *"
103 else:
104 return f"*/{seconds} * * * * *"
107def getenv(name: str) -> str:
108 env = os.getenv(name)
109 if env is None:
110 raise ValueError(f"{name} is not present in environment")
111 return env
114class OMS:
115 # Subclasses override these to swap in custom implementations without
116 # touching __init__. executor_cls is forwarded to OrderPlacementManager.
117 executor_cls: type[OrderExecutor] = OrderExecutor
118 position_cls: type[PositionManager] = PositionManager
119 opm_cls: type[OrderPlacementManager] = OrderPlacementManager
120 risk_cls: type[RiskEngine] = RiskEngine
122 def __init__(
123 self,
124 config: ConfigManager,
125 metric_stream: MetricStream,
126 rate_limiter: RateLimiter,
127 error_policy: ExchangeErrorPolicy | None = None,
128 insert_prefix: str = DEFAULT_METRIC_NAMESPACE,
129 signal_namespace: str | None = None,
130 observer: OMSEventHandler | None = None,
131 ):
132 super().__init__()
133 self.config = config
134 self.metric_stream = metric_stream
135 self.metric_builder = MetricBuilder(self.metric_stream, insert_prefix)
136 # Must match the PortfolioExecutor's namespace so the OMS subscribes to
137 # the same `portfolio_signal.<ns>.<portfolio_id>` the executor publishes.
138 self.signal_namespace = signal_namespace
139 self.observer = observer
140 # Per-exchange error classification; derive from config when not injected
141 error_policy = error_policy or config.config.credentials.to_error_policy()
142 self.position = self.position_cls(
143 config=config,
144 rate_limiter=rate_limiter,
145 )
146 # Owned here, not by the OPM: the feed serves both the executor's reads
147 # and the signal recompute, and a later Bybit adapter will share it.
148 self.price_feed = PriceFeed()
149 # Aggregation state for the event-time divergence report; see
150 # _note_event_time_divergence. Monotonic, so an NTP step cannot make a
151 # window look finished or eternal.
152 self._divergence_count = 0
153 self._divergence_worst_ms = 0
154 self._divergence_worst_symbol: str | None = None
155 self._divergence_window_started_at: float | None = None
156 self._divergence_last_emit_at: float | None = None
157 self._divergence_emitted_previous_window = False
158 self.opm = self.opm_cls(
159 position=self.position,
160 config=self.config,
161 rate_limiter=rate_limiter,
162 error_policy=error_policy,
163 executor_cls=self.executor_cls,
164 price_feed=self.price_feed,
165 )
166 self.risk = self.risk_cls(
167 config=self.config,
168 position=self.position,
169 rate_limiter=rate_limiter,
170 on_breach=self._trigger_shutdown,
171 )
172 self.scheduler = Scheduler()
173 self.previous_signal: PortfolioSignal | None = None
174 self.latest_signal: PortfolioSignal | None = None
175 self.rate_limiter = rate_limiter
176 # Initialised in run(); set here so on_refresh_config is safe to call
177 # before run() starts (e.g. in tests).
178 self.exchange_events_task: asyncio.Task | None = None
179 # Initialised in run(); set here so on_refresh_config and shutdown are
180 # safe to call before run() starts (e.g. in tests).
181 self.price_feed_task: asyncio.Task | None = None
182 self.price_feed_ws: ExchangeEvent | None = None
184 async def init(self):
185 """Initialise the OMS state when first started."""
186 logger.info("[INIT] Initilalizing position values")
188 # LATEST SIGNAL
189 self.latest_signal = await self.get_latest_signal(
190 self.config.config.portfolio_id,
191 )
192 logger.info(f"Latest signal at startup {self.latest_signal}")
194 # ORDER POOL + POSITION share one open-orders snapshot
195 snapshot = await self.opm.order_pools.fetch_open_orders_snapshot()
196 await self.opm.order_pools.resync_order_pool(snapshot)
197 await self.position.update_exchange()
198 self.position.update_pending(snapshot)
199 for s in self.config.config.base_asset_to_symbol_table.values():
200 symbol = Symbol(s)
201 if symbol not in self.position.pending.keys():
202 self.position.pending[symbol] = Position(
203 symbol=symbol,
204 quantity=Decimal("0"),
205 entry_price=Decimal("0"),
206 updated_time=datetime.now(tz=timezone.utc),
207 )
208 if symbol not in self.position.exchange.keys():
209 self.position.exchange[symbol] = Position(
210 symbol=symbol,
211 quantity=Decimal("0"),
212 entry_price=Decimal("0"),
213 updated_time=datetime.now(tz=timezone.utc),
214 )
216 # Init using desired and exchange
217 quantity = (
218 self.position.exchange[symbol].quantity
219 + self.position.pending[symbol].quantity
220 )
221 self.position.desired[symbol] = Position(
222 symbol=symbol,
223 quantity=quantity,
224 entry_price=Decimal("0"),
225 updated_time=datetime.now(tz=timezone.utc),
226 )
228 async def get_latest_signal(self, portfolio_id: str) -> PortfolioSignal | None:
229 """
230 Cold-start hook: fetch the last known signal so the OMS does not start
231 blind. The base has no signal store, so it starts without one and waits
232 for the first live signal; subclasses override to read their own store.
233 """
234 return None
236 def _setup_signals(self):
237 """
238 SIGTERM stops the scheduler, not _handle_shutdown() directly: run()'s
239 finally already calls _handle_shutdown() exactly once whenever
240 scheduler.start() returns, for any reason. Calling it again here
241 would race that (double order-cancel pass, and a SystemExit raised
242 inside this handler's own task wouldn't propagate to end the
243 process anyway - it would just be a swallowed background-task
244 exception). Stopping the scheduler reuses the existing, single
245 shutdown path instead of adding a second one.
246 """
247 loop = asyncio.get_event_loop()
249 loop.add_signal_handler(
250 signal.SIGTERM, lambda: asyncio.create_task(self.scheduler.shutdown())
251 )
253 async def _trigger_shutdown(self):
254 """
255 Single shutdown entry point for risk breaches (and anything else that
256 must stop the OMS from inside a running handler): stop the scheduler,
257 which makes scheduler.start() return, and run()'s finally performs the
258 one order-cancel pass and process exit. Calling _handle_shutdown()
259 directly from a handler would cancel orders but leave the scheduler
260 ticking - the next placement tick would put every position right back.
261 (Same reasoning as the SIGTERM handler, see _setup_signals.)
262 """
263 logger.error("[SHUTDOWN] Triggered - stopping scheduler")
264 await self.scheduler.shutdown()
266 async def _handle_shutdown(self):
267 """To close all pending orders on shutdown"""
268 logger.info("Shutdown signal received. Cancelling orders...")
270 self._stop_price_feed()
272 # Snapshot under the pool lock, then release BEFORE the gather: each
273 # cancel_single_order re-acquires the same lock, so holding it across
274 # the gather would self-deadlock.
275 async with self.opm.order_pools.get_order_pool() as order_pool:
276 orders_to_cancel = list(order_pool.values())
278 sem = asyncio.Semaphore(MAX_CONCURRENT_ORDER_OPS)
280 async def _bounded_cancel(symbol: str, client_order_id: str):
281 async with sem:
282 return await self.opm.executor.cancel_single_order(
283 Symbol(symbol), client_order_id
284 )
286 cancel_tasks = [
287 _bounded_cancel(order.symbol, order.client_order_id)
288 for order in orders_to_cancel
289 ]
291 if not cancel_tasks:
292 raise SystemExit(0)
294 cancel_results = await asyncio.gather(*cancel_tasks, return_exceptions=True)
296 cancel_retries = []
297 for result in cancel_results:
298 if isinstance(result, Exception):
299 logger.error(f"Failed to cancel order: {result}")
300 if isinstance(result, CancelBacklogs):
301 cancel_retries.append(result)
303 if not cancel_retries:
304 logger.info("All orders cancelled successfully.")
305 return
307 logger.warning(
308 f"Retrying {len(cancel_retries)} cancellations in "
309 f"{SHUTDOWN_CANCEL_RETRY_DELAY_SEC}s..."
310 )
311 try:
312 await asyncio.sleep(SHUTDOWN_CANCEL_RETRY_DELAY_SEC)
313 except asyncio.CancelledError:
314 logger.error("Shutdown forced during retry wait.")
315 return
317 retry_tasks = [
318 _bounded_cancel(retry.symbol, retry.client_order_id)
319 for retry in cancel_retries
320 ]
322 if retry_tasks:
323 await asyncio.gather(*retry_tasks, return_exceptions=True)
324 logger.info("Retry attempts finished.")
326 raise SystemExit(0)
328 async def _insert_equity_metric(
329 self, oms_id: str, balance_endpoint: Endpoints
330 ) -> None:
331 """
332 Read the wallet balance from the exchange and record equity.
334 The rate-limit guard deliberately covers the EXCHANGE call only. It used
335 to wrap the aegis write too, which meant a ClickHouse outage -- nothing
336 to do with the exchange -- was charged to the wallet rate-limit pool.
337 Because the limiter only adopts the exchange's own quota headers on
338 success, every such failure decremented the pool without ever learning
339 the real quota, and once it reached zero the pool refused for the life of
340 the process. Reported from production as "UID_WALLET: 0/None, never
341 recovers", with the dashboard balance falling back to the static
342 initial_balance -- one root cause, two symptoms.
344 Extracted from on_aegis_update so that guard scope is explicit and
345 testable, since the scope IS the bug.
347 The balance is read fresh here on every call by design; see the spec's
348 Balances section. No streamed balance substitutes for it.
349 """
350 async with self.rate_limiter.guard(endpoint=balance_endpoint):
351 balance = await self.config.exchange.get_wallet_balance()
353 logger.debug("Inserting equity to aegis")
354 await self.metric_builder.create_equity(
355 oms_id=oms_id, equity=str(balance.margin_balance)
356 )
358 def _supervise_task(self, task: asyncio.Task, label: str) -> None:
359 """
360 Log when a long-lived stream task ends. Logging only, never restarts.
362 Nothing ever awaits these tasks, so without this "started and healthy" and
363 "started and dead" are indistinguishable from the OMS. Both endings are
364 silent today:
366 - cybotrade's `_stream` catches stream errors, prints and breaks, so
367 `start()` returns *normally* once a live socket dies.
368 - `persist_conn_with` retries only *reconnects*; it uses `?` on the initial
369 connect, and in `_stream` that await sits outside the try, so failing to
370 connect at all escapes into the task, where nobody retrieves it.
372 Deliberately does not restart: a restart loop against a hard-failing
373 connect would hammer the exchange, and the price feed already fails safe
374 (PriceFeed withholds quotes once liveness lapses, so reads go to REST).
375 Retrieving the exception here also stops asyncio complaining about it.
376 """
378 def _on_done(finished: asyncio.Task) -> None:
379 if finished.cancelled():
380 logger.info(f"[{label}] Task cancelled")
381 return
382 exc = finished.exception()
383 if exc is not None:
384 logger.error(f"[{label}] Task failed: {exc!r}", exc_info=exc)
385 return
386 logger.error(
387 f"[{label}] Task ended on its own, so the stream stopped "
388 f"delivering and this connection will not come back"
389 )
391 task.add_done_callback(_on_done)
393 async def on_price_feed_event(self, event: Event) -> None:
394 """
395 Translate public market-data events into the feed.
397 Every event refreshes liveness, including ones that carry no quote: an
398 unparseable frame still proves the socket is delivering. Subscribed means
399 the connection just (re)established, so nothing cached survives it.
401 Async to satisfy the adapter's callback contract — BinancePublicWS does
402 `await self.on_event(event)`, and a plain def there raises on every frame
403 — not because the translation itself needs to await anything.
404 """
405 match event.event_type:
406 case EventType.BookTicker:
407 book_ticker = event.data
408 self._note_event_time_divergence(book_ticker)
409 self.price_feed.apply(
410 book_ticker.symbol, book_ticker.bid, book_ticker.ask
411 )
412 case EventType.Subscribed:
413 logger.info(f"[PRICE_FEED] (Re)subscribed to {event.data}, clearing")
414 self.price_feed.clear()
415 self.price_feed.note_message()
416 case _:
417 self.price_feed.note_message()
419 def _note_event_time_divergence(self, book_ticker) -> None:
420 """
421 Record a frame whose exchange timestamp trails our synced clock, and
422 report at most one summary per PRICE_FEED_DIVERGENCE_LOG_INTERVAL_SEC.
424 Detection is per frame; reporting is not. One stall delays every frame
425 behind it, so a per-frame line describes a single event once per frame --
426 156 of them in a minute, observed in production. Count plus worst value
427 is both quieter and more informative.
429 Severity escalates rather than starting loud: an isolated stall (startup,
430 a GC pause) is not actionable, whereas the same thing two windows running
431 means the loop is persistently behind and quote age is understating
432 staleness for real.
434 The whole body is guarded. "Never let a metric break the feed" only holds
435 if every step is covered, and a raise here would propagate through
436 on_price_feed_event into the adapter, where start() swallows it per frame
437 -- losing the quote and leaving no OMS-level trace.
438 """
439 try:
440 now = time.monotonic()
441 exchange_ms = int(book_ticker.event_time.timestamp() * 1000)
442 divergence_ms = self.rate_limiter.get_synced_time_ms() - exchange_ms
444 if divergence_ms > PRICE_FEED_EVENT_TIME_DIVERGENCE_WARN_MS:
445 if self._divergence_window_started_at is None:
446 self._divergence_window_started_at = now
447 # A stall long after the last report is a fresh incident, not
448 # a continuation, so it starts quiet again.
449 last = self._divergence_last_emit_at
450 if (
451 last is not None
452 and now - last > 2 * PRICE_FEED_DIVERGENCE_LOG_INTERVAL_SEC
453 ):
454 self._divergence_emitted_previous_window = False
455 self._divergence_count += 1
456 if divergence_ms > self._divergence_worst_ms:
457 self._divergence_worst_ms = divergence_ms
458 self._divergence_worst_symbol = str(book_ticker.symbol)
460 started = self._divergence_window_started_at
461 if (
462 started is None
463 or now - started < PRICE_FEED_DIVERGENCE_LOG_INTERVAL_SEC
464 ):
465 return
467 count = self._divergence_count
468 worst = self._divergence_worst_ms
469 symbol = self._divergence_worst_symbol
470 elapsed = now - started
471 # Reset before emitting, so a failure inside logging cannot wedge the
472 # window open and suppress every later report.
473 self._divergence_count = 0
474 self._divergence_worst_ms = 0
475 self._divergence_worst_symbol = None
476 self._divergence_window_started_at = None
477 self._divergence_last_emit_at = now
479 message = (
480 f"[PRICE_FEED] Event-time divergence: {count} frame(s) in the "
481 f"last {elapsed:.0f}s, worst {worst}ms ({symbol}). The loop "
482 f"stalled or the clock has drifted, so quote age understates "
483 f"real staleness"
484 )
485 if self._divergence_emitted_previous_window:
486 logger.warning(message)
487 else:
488 logger.info(message)
489 self._divergence_emitted_previous_window = True
490 except Exception:
491 return
493 def _start_price_feed(self) -> None:
494 """
495 Start the public feed for the configured symbols.
497 Only Binance and Bybit have an adapter today; on any other exchange
498 the OMS runs exactly as before, reading prices from REST.
499 """
500 if self.price_feed_task is not None and not self.price_feed_task.done():
501 # Starting without stopping would orphan the running socket, which
502 # would keep writing into the shared cache with nobody able to cancel it.
503 logger.warning(
504 "[PRICE_FEED] Already running, not starting a second connection"
505 )
506 return
507 symbols = list(self.config.config.base_asset_to_symbol_table.values())
508 self.price_feed_ws = self.config.config.credentials.to_public_exchange_event(
509 symbols=symbols
510 )
511 if self.price_feed_ws is None:
512 logger.info(
513 "[PRICE_FEED] No public feed for "
514 f"{self.config.config.credentials.exchange}, using REST prices"
515 )
516 return
517 self.price_feed_ws.on_event = self.on_price_feed_event
518 self.price_feed_task = asyncio.create_task(self.price_feed_ws.start())
519 self._supervise_task(self.price_feed_task, "PRICE_FEED")
520 # .topics (Bybit) and .streams (Binance) are mutually exclusive today,
521 # but `or` short-circuits rather than evaluating both eagerly, so a
522 # future adapter that grows both wouldn't have `topics` silently win.
523 subscriptions = getattr(self.price_feed_ws, "topics", None) or getattr(
524 self.price_feed_ws, "streams", None
525 )
526 logger.info(
527 f"[PRICE_FEED] Started {type(self.price_feed_ws).__name__} for {subscriptions}"
528 )
530 def _stop_price_feed(self) -> None:
531 if self.price_feed_task is not None:
532 self.price_feed_task.cancel()
533 self.price_feed_task = None
534 # Drop the adapter with the task: after a switch away from any
535 # exchange with a public feed, this would otherwise still point at
536 # the dead connection's adapter.
537 self.price_feed_ws = None
538 self.price_feed.clear()
540 async def on_refresh_config(self):
541 """To refresh and update config if there are any changes made during runtime"""
542 old_config = copy.deepcopy(self.config.config)
543 await self.config.refresh()
544 self.opm.executor.config = self.config.config
546 # Both branches below can invalidate the feed's connection. Restart it
547 # once, after they have run, so a refresh that changes both does not open
548 # a socket only to cancel it.
549 needs_price_feed_restart = False
551 # Restart with a new exchange events when credentials have changed
552 if old_config.credentials != self.config.config.credentials:
553 logger.info(
554 "Detected credentials update, refreshing exchange event handler..."
555 )
556 if self.exchange_events_task is not None:
557 self.exchange_events_task.cancel()
558 self.exchange_event = self.config.config.credentials.to_exchange_event()
559 self.exchange_event.on_event = self.opm.on_exchange_event
560 self.exchange_events_task = asyncio.create_task(self.exchange_event.start())
561 self._supervise_task(self.exchange_events_task, "EXCHANGE_EVENTS")
562 self.opm.executor.exchange = self.config.exchange
563 self.opm.order_pools.exchange = self.config.exchange
564 # The exchange and the testnet flag both live in credentials, and both
565 # decide whether there is a feed at all and which book it reads. A
566 # feed left running after this would quote the previous exchange.
567 needs_price_feed_restart = True
569 if (
570 old_config.base_asset_to_symbol_table
571 != self.config.config.base_asset_to_symbol_table
572 ):
573 try:
574 await self.config.update_symbol_info(self.rate_limiter, force=True)
575 except Exception as e:
576 logger.warning(f"update symbol info failed due to {e}")
578 await self.init()
580 # The stream list is encoded in the feed's URL, so a changed symbol
581 # table needs a new connection.
582 needs_price_feed_restart = True
584 if needs_price_feed_restart:
585 # clear() happens in _stop_price_feed: quotes for symbols we may no
586 # longer trade, or from an exchange we no longer talk to, must not linger.
587 self._stop_price_feed()
588 self._start_price_feed()
590 async def on_portfolio_signal(self, msg: Msg):
591 """To store the latest signal from portfolio server"""
592 try:
593 payload = json.loads(msg.data.decode())
594 portfolio_signal = PortfolioSignal(
595 assets=payload["assets"],
596 timestamp=int(payload["timestamp"]),
597 )
598 logger.info(f"Portfolio signal retrieved {portfolio_signal}")
600 if sum(abs(p) for p in portfolio_signal.assets.values()) > Decimal("1"):
601 logger.warning(
602 "[ON_PROCESS_LATEST_SIGNAL] Current sum of positions in latest signal is more than 1"
603 )
604 return
606 unknown_assets = [
607 asset
608 for asset in portfolio_signal.assets
609 if asset not in self.config.config.base_asset_to_symbol_table
610 ]
611 if unknown_assets:
612 logger.error(
613 f"[ON_PORTFOLIO_SIGNAL] Rejecting signal with unknown asset(s) "
614 f"{unknown_assets}, not in base_asset_to_symbol_table"
615 )
616 return
618 self.latest_signal = portfolio_signal
619 if self.observer is not None:
620 self.observer.on_signal_received(portfolio_signal)
621 except Exception as e:
622 logger.error(f"Failed to process portfolio signal due to {e}")
624 async def on_command(self, msg: Msg):
625 """Control-plane callback: an operator sent a command on this OMS's
626 command subject. Run the matching operation and, when the caller used
627 request-reply (msg.reply set), publish the outcome back.
629 One reply point with a result dict keeps every path — parse error,
630 unknown command, failure, success — replying exactly once.
631 """
632 try:
633 payload = json.loads(msg.data.decode())
634 command = payload["command"]
635 except Exception as e:
636 logger.error(f"[ON_COMMAND] Failed to parse command message: {e}")
637 await self._reply(msg, {"status": "error", "error": f"bad message: {e}"})
638 return
640 try:
641 if command == "rebalance":
642 await self.rebalance()
643 result = {"status": "ok", "command": command}
644 else:
645 logger.warning(f"[ON_COMMAND] Unknown command: {command}")
646 result = {"status": "error", "error": f"unknown command: {command}"}
647 except Exception as e:
648 logger.error(f"[ON_COMMAND] Command '{command}' failed: {e}")
649 result = {"status": "error", "command": command, "error": str(e)}
651 await self._reply(msg, result)
653 async def _reply(self, msg: Msg, result: dict):
654 """Publish a result to the requester's reply inbox. No-op for
655 fire-and-forget callers, whose msg.reply is empty."""
656 reply_to = getattr(msg, "reply", "")
657 if not reply_to:
658 return
659 try:
660 await self.metric_stream.publish(reply_to, json.dumps(result).encode())
661 except Exception as e:
662 logger.error(f"[ON_COMMAND] Failed to reply on {reply_to}: {e}")
664 async def on_process_latest_signal(self):
665 """
666 Periodic: recompute desired from the latest signal, skipping assets
667 whose weight is unchanged. Use rebalance() to force a recompute at the
668 current price even when the signal is static.
669 """
670 await self._recompute_desired(force=False)
672 async def _recompute_desired(self, force: bool = False):
673 """
674 Turn the latest signal into desired positions at the current price.
676 Desired quantity is price-dependent (balance * leverage * weight /
677 price), so with force=True the weight-unchanged skip is bypassed and
678 desired is repriced against a moved market even if the signal has not
679 changed.
680 """
681 if not self.latest_signal:
682 logger.warning("[RECOMPUTE_DESIRED] There is no signal to act on")
683 return
685 market_quotes: dict[Symbol, Decimal] = {}
686 PRECISION_4 = Decimal("0.0001")
687 symbol_table = self.config.config.base_asset_to_symbol_table
689 # First pass: decide which assets need a recompute, before touching
690 # any shared state
691 to_recompute: dict[str, Decimal] = {}
692 for asset, weightage in self.latest_signal.assets.items():
693 symbol = Symbol(symbol_table[asset])
694 # .get: a newly added asset has no entry in the previous signal
695 previous_weightage = (
696 self.previous_signal.assets.get(asset) if self.previous_signal else None
697 )
698 if (
699 not force
700 and previous_weightage is not None
701 and previous_weightage.quantize(exp=PRECISION_4)
702 == weightage.quantize(exp=PRECISION_4)
703 ):
704 logger.info(
705 f"There is no significant change in signal weights for {symbol}, it is ignored"
706 )
707 if self.observer is not None:
708 self.observer.on_signal_skipped(symbol, "no_change")
709 continue
710 to_recompute[asset] = weightage
712 if to_recompute:
713 recompute_symbols = {
714 str(Symbol(symbol_table[asset])) for asset in to_recompute
715 }
716 async with self.opm.order_pools.get_order_backlog() as order_backlog:
717 order_backlog[:] = [
718 b for b in order_backlog if b.symbol not in recompute_symbols
719 ]
721 for asset, weightage in to_recompute.items():
722 symbol = Symbol(symbol_table[asset])
723 if symbol not in market_quotes.keys():
724 price = await self.opm.executor.get_current_price(symbol=symbol)
725 if price is None:
726 logger.warning(
727 f"[RECOMPUTE_DESIRED] No price for {symbol}, skipping this cycle"
728 )
729 return
730 market_quotes[symbol] = price
732 quantity = self.position.compute_base_quantity(
733 price=market_quotes[symbol], weightage=weightage
734 )
735 self.position.desired[symbol] = Position(
736 symbol=symbol,
737 quantity=quantity,
738 entry_price=market_quotes[symbol],
739 updated_time=datetime.now(tz=timezone.utc),
740 )
741 desired_position = self.position.desired[symbol]
742 logger.debug(
743 f"[RECOMPUTE_DESIRED] {self.config.config.portfolio_id} wants {desired_position.quantity} of {desired_position.symbol} at the price of {desired_position.entry_price}"
744 )
745 if self.observer is not None:
746 self.observer.on_desired_updated(symbol, desired_position.quantity)
748 self.position.desired = self.risk.cap_desired(self.position.desired)
749 self.previous_signal = self.latest_signal
751 async def rebalance(self):
752 """
753 Reprice desired against the current market even when the signal is
754 static (bypassing the weight-unchanged skip). Placement is left to the
755 scheduled on_order_placement tick, so this never races the cron and
756 needs no placement lock.
757 """
758 logger.info("[REBALANCE] repricing desired at current market")
759 await self._recompute_desired(force=True)
761 async def on_aegis_update(self):
762 """
763 To upsert latest equity value from exchange to aegis``
764 """
765 logger.info("[ON_AEGIS_UPDATE]")
767 # The 60s cadence this handler runs on is the liveness anchor for the
768 # streamed exchange position (see POSITION_ANCHOR_MAX_AGE_SEC) -- it
769 # must not be contingent on the aegis/metrics path below, which reads
770 # from a separate DB and exchange call that can fail independently.
771 # update_exchange() already swallows and logs its own failures, so a
772 # bad read here degrades to the anchor aging out rather than raising.
773 try:
774 await self.position.update_exchange()
775 except Exception as e:
776 logger.warning(f"Failed to refresh exchange positions due to {e}")
778 async def _check_and_sync_trade(
779 sem,
780 oms_id: str,
781 package_id: str,
782 record: tuple[Symbol, str, Decimal, datetime],
783 ):
784 """
785 Worker function.
786 Returns: (package_id, record) IF it is fully filled and synced or 0-filled.
787 Returns: None IF it is pending or failed.
788 """
789 async with sem:
790 symbol, client_order_id, start_price, start_time = (
791 record[0],
792 record[1],
793 record[2],
794 record[3],
795 )
796 asset = parts[0] if symbol and (parts := symbol.split()) else ""
798 try:
799 async with self.rate_limiter.guard(
800 endpoint=Endpoints.GET_ORDER_DETAILS
801 ):
802 result = (
803 await self.config.exchange.get_order_details_from_history(
804 symbol=symbol,
805 client_order_id=client_order_id,
806 )
807 )
808 except Exception as e:
809 logger.warning(f"Failed to fetch {client_order_id}: {e}")
810 return None # Failed fetch, keep in pool
812 if not result:
813 age = datetime.now(timezone.utc) - start_time.astimezone(
814 timezone.utc
815 )
816 if age > AEGIS_NOT_FOUND_GIVE_UP:
817 logger.error(
818 f"[ON_AEGIS_UPDATE] {client_order_id} still not in exchange history after {age}, dropping record"
819 )
820 return (package_id, record)
821 logger.warning(
822 f"[ON_AEGIS_UPDATE] {client_order_id} NOT FOUND IN EXCHANGE HISTORY"
823 )
824 return None # Not found, keep in pool
826 if result.status in (
827 OrderStatus.CREATED,
828 OrderStatus.PARTIALLY_FILLED,
829 ):
830 # Still live; syncing now would freeze a partial fill as
831 # the final trade and later fills would never reach aegis
832 return None
834 if result.filled_size == Decimal("0"):
835 # Terminal with nothing filled, no trade will ever come
836 return (package_id, record)
838 match result.side:
839 case OrderSide.BUY:
840 order_side = 1
841 case OrderSide.SELL:
842 order_side = -1
843 case OrderSide.NONE:
844 logger.error("[ON_AEGIS_UPDATE] Orderside shouldn't be NONE")
845 order_side = 0
847 try:
848 await self.metric_builder.create_trade(
849 oms_id=oms_id,
850 package_id=package_id,
851 client_order_id=client_order_id,
852 asset=asset,
853 symbol=str(symbol),
854 exchange=self.config.exchange.exchange(),
855 start_quantity=str(order_side * result.size),
856 executed_quantity=str(order_side * result.filled_size),
857 executed_price=str(result.price),
858 executed_time=int(
859 result.updated_time.astimezone(tz=timezone.utc).timestamp()
860 * 1_000_000
861 )
862 * 1_000,
863 start_time=int(
864 start_time.astimezone(tz=timezone.utc).timestamp()
865 * 1_000_000
866 )
867 * 1_000,
868 start_price=str(start_price),
869 )
871 return (package_id, record)
873 except Exception as e:
874 logger.error(f"Failed to save {client_order_id} to Aegis: {e}")
875 return None # DB Write failed, keep in pool to retry next time
877 balance_endpoint = Endpoints.GET_WALLET_BALANCE
878 try:
879 oms_id = self.config.config.oms_id
880 logger.debug("Inserting trades to aegis")
882 sem = asyncio.Semaphore(2)
883 tasks = []
885 # Orders still in the local pool are live: nothing terminal to
886 # sync yet, polling their history only burns the shared budget
887 async with self.opm.order_pools.get_order_pool() as order_pool:
888 live_ids = set(order_pool.keys())
890 for package_id, records in list(self.opm.order_pools.order_records.items()):
891 for record in records:
892 if record[1] in live_ids:
893 continue
894 tasks.append(_check_and_sync_trade(sem, oms_id, package_id, record))
896 if tasks:
897 results = await asyncio.gather(*tasks, return_exceptions=True)
898 for res in results:
899 if isinstance(res, BaseException):
900 logger.error(f"Trade sync error: {res}")
901 continue
902 if res:
903 filled_pkg_id, filled_record = res
904 try:
905 self.opm.order_pools.order_records[filled_pkg_id].remove(
906 filled_record
907 )
908 if not self.opm.order_pools.order_records[filled_pkg_id]:
909 del self.opm.order_pools.order_records[filled_pkg_id]
910 except (ValueError, KeyError):
911 pass
913 # EQUITY UPDATE
914 await self._insert_equity_metric(oms_id, balance_endpoint)
916 # POSITION UPDATE
917 logger.debug("Inserting position to aegis")
918 # Anchor already refreshed at the top of this handler, independently
919 # of the try below -- no second read needed here.
920 for position in self.position.exchange.values():
921 asset = parts[0] if (parts := position.symbol.split()) else ""
922 await self.metric_builder.create_position(
923 oms_id=oms_id,
924 asset=asset,
925 symbol=str(position.symbol),
926 exchange=self.config.exchange.exchange(),
927 quantity=str(position.quantity),
928 price=str(position.entry_price),
929 updated_time=int(
930 position.updated_time.astimezone(tz=timezone.utc).timestamp()
931 * 1_000_000
932 )
933 * 1_000,
934 )
935 except Exception as e:
936 logger.warning(f"Failed to upsert to aegis due to {e}")
938 async def run(self):
939 try:
940 await self.init()
941 self._setup_signals()
942 await self.metric_stream.subscribe(
943 portfolio_signal_subject(
944 self.config.config.portfolio_id, self.signal_namespace
945 ),
946 callback=self.on_portfolio_signal,
947 )
949 # Control plane: operator commands (e.g. rebalance) for this OMS,
950 # over the same broker on a dedicated command subject.
951 await self.metric_stream.subscribe(
952 oms_command_subject(self.config.config.oms_id, self.signal_namespace),
953 callback=self.on_command,
954 )
956 await self.scheduler.schedule(
957 id="on_refresh_config",
958 handler=self.on_refresh_config,
959 trigger=Trigger.Cron("*/2 * * * * *"), # every 2 seconds
960 )
962 await self.scheduler.schedule(
963 id="on_aegis_update",
964 handler=self.on_aegis_update,
965 trigger=Trigger.Cron("*/1 * * * *"), # every 1 minute
966 )
968 await self.scheduler.schedule(
969 id="on_process_latest_signal",
970 handler=self.on_process_latest_signal,
971 trigger=Trigger.Cron("*/5 * * * * *"), # every 5 seconds
972 )
974 await self.scheduler.schedule(
975 id="on_order_placement",
976 handler=self.opm.on_order_placement,
977 trigger=Trigger.Cron(
978 generate_cron(self.config.config.order_placement_interval)
979 ), # every 15 seconds
980 )
982 await self.scheduler.schedule(
983 id="on_retry_backlog",
984 handler=self.opm.on_retry_backlog,
985 trigger=Trigger.Cron("*/2 * * * * *"), # 2 seconds
986 )
988 await self.scheduler.schedule(
989 id="on_order_expiry_check",
990 handler=self.opm.on_order_expiry_check,
991 trigger=Trigger.Cron(generate_cron(self.config.config.expiry_check)),
992 )
994 await self.scheduler.schedule(
995 id="on_resync_time",
996 handler=self.rate_limiter.on_resync_time,
997 # Hourly, not daily. Every cooldown deadline comparison and
998 # Binance's weight-window reset are computed against
999 # get_synced_time_ms(), so drift accumulated over a day either
1000 # releases a cooldown early or resets the weight counter on the
1001 # wrong side of the exchange's minute boundary. The call itself
1002 # costs weight 1.
1003 trigger=Trigger.Cron("0 * * * *"), # every hour
1004 )
1006 await self.scheduler.schedule(
1007 id="on_risk_check",
1008 handler=self.risk.run_risk_checks,
1009 trigger=Trigger.Cron("*/10 * * * * *"), # every 10 seconds
1010 )
1012 self.exchange_event = self.config.config.credentials.to_exchange_event()
1013 self.exchange_event.on_event = self.opm.on_exchange_event
1014 self.exchange_events_task = asyncio.create_task(self.exchange_event.start())
1015 self._supervise_task(self.exchange_events_task, "EXCHANGE_EVENTS")
1016 self._start_price_feed()
1017 await self.scheduler.start()
1018 except Exception:
1019 logger.exception("OMS run() terminated unexpectedly")
1020 raise
1021 finally:
1022 await self._handle_shutdown()