"""Defer signals that arrive outside US market hours, drain at next market open. Kevin's signals are mid/long-term (weeks/months). A signal that lands at Sunday 19:00 UTC should turn into a Monday-morning paper trade, not be dropped on the floor. This queue holds (signal, target_submission_ts) pairs in a Redis sorted-set keyed by target_submission_ts. A background drain task in trade-executor's run() loop polls pop_due() every ~60 s; signals whose target_submission_ts <= now are reprocessed through the normal process_signal path. """ from __future__ import annotations import json import logging from datetime import datetime, timezone from redis.asyncio import Redis from shared.schemas.trading import TradeSignal logger = logging.getLogger(__name__) DEFERRED_KEY = "kevin:deferred_signals" class DeferredSignalQueue: """Redis-sorted-set wrapper. Score = target_submission_ts (epoch seconds). Member = JSON of {"signal": , "queued_at": }. """ def __init__(self, redis: Redis) -> None: self.redis = redis async def defer(self, signal: TradeSignal, target_ts: datetime) -> None: """Add a signal to the queue, scheduled for re-submission at ``target_ts`` (usually the next market-open timestamp). """ member = json.dumps( { "signal": signal.model_dump(mode="json"), "queued_at": datetime.now(timezone.utc).isoformat(), } ) await self.redis.zadd(DEFERRED_KEY, {member: target_ts.timestamp()}) async def pop_due( self, now: datetime | None = None ) -> list[tuple[TradeSignal, datetime]]: """Atomically pop and return every signal whose target_ts <= now. Returns: list of (TradeSignal, queued_at) pairs. """ now = now or datetime.now(timezone.utc) cutoff = now.timestamp() async with self.redis.pipeline(transaction=True) as pipe: pipe.zrangebyscore(DEFERRED_KEY, min="-inf", max=cutoff) pipe.zremrangebyscore(DEFERRED_KEY, min="-inf", max=cutoff) members, _removed = await pipe.execute() result: list[tuple[TradeSignal, datetime]] = [] for raw in members: try: if isinstance(raw, bytes): raw = raw.decode("utf-8") payload = json.loads(raw) signal = TradeSignal.model_validate(payload["signal"]) queued_at = datetime.fromisoformat(payload["queued_at"]) result.append((signal, queued_at)) except Exception: logger.exception("Failed to deserialize deferred signal: %s", raw) return result async def size(self) -> int: return await self.redis.zcard(DEFERRED_KEY)