trading/tests/services/trade_executor/test_deferred_queue.py
Viktor Barzin 2855e79af4 feat(trade-executor): defer outside_market_hours signals to next open
Kevin's signals are mid-long term (weeks/months) and he uploads almost
exclusively pre-market or evenings. Before this change, every such
signal hit RiskManager.outside_market_hours, got consumed off the
Redis stream, and was lost. End result: 71 emitted signals, 0 trades.

New behaviour: when RiskManager rejects with outside_market_hours,
push the signal into a Redis sorted-set keyed by next_market_open
(via Alpaca's clock API — handles weekends + holidays). A background
drain task polls the set every kevin_defer_drain_interval_s (60s);
any signal whose target <= now gets re-run through process_signal.

Safety:
  - kevin_max_defer_hours (default 72h) caps signal staleness so we
    don't trade on week-old views.
  - Other RiskManager rejections (cooldown, kill-switch, drawdown
    halt) fall through to the existing drop path.
  - kevin_defer_outside_market_hours toggle defaults True; flip to
    false for legacy behaviour.

Slack: new notify_deferred() emits "🕒 Meet Kevin: DEFERRED
NVDA until Mon 13:30 UTC (market closed; conviction 0.85)" instead
of the noisy outside_market_hours rejection spam.

Tests: 5 queue + 4 integration = 9 new, all 32 trade-executor tests
GREEN.
2026-06-01 19:01:37 +00:00

101 lines
2.9 KiB
Python

"""Tests for the DeferredSignalQueue.
Kevin's signals are mid/long-term — a signal that lands outside US market
hours should be held in a Redis sorted-set and replayed at the next market
open instead of dropped.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from uuid import uuid4
import fakeredis.aioredis
import pytest
from services.trade_executor.deferred_queue import DeferredSignalQueue
from shared.schemas.trading import SignalDirection, TradeSignal
def _signal(ticker: str = "NVDA") -> TradeSignal:
return TradeSignal(
signal_id=uuid4(),
ticker=ticker,
direction=SignalDirection.LONG,
strength=0.85,
strategy_sources=["meet_kevin:buy:0.85"],
timestamp=datetime.now(timezone.utc),
target_dollars=Decimal("2000"),
)
@pytest.fixture
async def redis():
r = fakeredis.aioredis.FakeRedis()
try:
yield r
finally:
await r.aclose()
async def test_defer_then_pop_due_returns_signal(redis):
q = DeferredSignalQueue(redis)
s = _signal()
past = datetime.now(timezone.utc) - timedelta(minutes=1)
await q.defer(s, target_ts=past)
due = await q.pop_due()
assert len(due) == 1
popped_signal, _queued_at = due[0]
assert popped_signal.ticker == "NVDA"
assert popped_signal.signal_id == s.signal_id
async def test_pop_due_skips_future_targets(redis):
q = DeferredSignalQueue(redis)
s_now = _signal("NVDA")
s_future = _signal("AMD")
now = datetime.now(timezone.utc)
await q.defer(s_now, target_ts=now - timedelta(seconds=10))
await q.defer(s_future, target_ts=now + timedelta(hours=12))
due = await q.pop_due()
assert [s.ticker for s, _ in due] == ["NVDA"]
remaining = await q.size()
assert remaining == 1
async def test_pop_due_is_atomic_pop(redis):
"""Once popped, a signal must NOT come back on the next pop."""
q = DeferredSignalQueue(redis)
await q.defer(_signal(), target_ts=datetime.now(timezone.utc) - timedelta(seconds=1))
first = await q.pop_due()
assert len(first) == 1
second = await q.pop_due()
assert second == []
async def test_size_zero_when_empty(redis):
q = DeferredSignalQueue(redis)
assert await q.size() == 0
async def test_defer_preserves_signal_age(redis):
"""The original TradeSignal.timestamp (which the strategy uses for
mention-age cutoffs) must survive the round-trip."""
q = DeferredSignalQueue(redis)
orig_ts = datetime(2026, 6, 1, 15, 0, tzinfo=timezone.utc)
s = TradeSignal(
signal_id=uuid4(),
ticker="MSFT",
direction=SignalDirection.LONG,
strength=0.7,
strategy_sources=["meet_kevin"],
timestamp=orig_ts,
)
await q.defer(s, target_ts=datetime.now(timezone.utc) - timedelta(seconds=1))
[(popped, _)] = await q.pop_due()
assert popped.timestamp == orig_ts