feat(ai_trade): poll_loop가 매 cycle 끝에 expired signal purge (F5 part 4)

Phase 5 consumer(agent-office /signal)가 안 붙은 상태에서도 state.signals가
무한 누적되지 않도록 매 cycle 끝에 state.purge_expired_signals(now) 호출.
expires_at < now인 signal 자동 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 20:01:40 +09:00
parent e4d02b8059
commit ac6409605c
2 changed files with 66 additions and 0 deletions

View File

@@ -54,6 +54,11 @@ async def poll_loop(
generate_signals(state, dedup, settings) generate_signals(state, dedup, settings)
except Exception: except Exception:
logger.exception("generate_signals failed") logger.exception("generate_signals failed")
# F5: cycle 끝에 expired signal purge (consumer 미사용 케이스 보호)
try:
state.purge_expired_signals(datetime.now(KST))
except Exception:
logger.exception("purge_expired_signals failed")
interval = _next_interval(now) interval = _next_interval(now)
try: try:
await asyncio.wait_for(shutdown.wait(), timeout=interval) await asyncio.wait_for(shutdown.wait(), timeout=interval)

View File

@@ -178,3 +178,64 @@ async def test_post_close_fires_at_1601_when_not_yet_today(monkeypatch):
) )
assert post_close.await_count >= 1, "post-close가 16:01에 호출되지 않음 (F3 회귀)" assert post_close.await_count >= 1, "post-close가 16:01에 호출되지 않음 (F3 회귀)"
async def test_poll_loop_purges_expired_signals(monkeypatch):
"""F5 — 매 cycle 끝에 expired signal이 제거됨."""
from datetime import datetime as _dt
from zoneinfo import ZoneInfo as _ZI
import asyncio as _asyncio
from ai_trade import pull_worker
from ai_trade.state import PollState
_kst = _ZI("Asia/Seoul")
now = _dt(2026, 5, 18, 10, 0, tzinfo=_kst)
class FrozenDT:
@staticmethod
def now(tz=None):
return now
state = PollState()
state.signals = {
"OLD": {
"ticker": "OLD",
"expires_at": _dt(2026, 5, 18, 9, 0, tzinfo=_kst).isoformat(),
"cycle_id": 1,
},
"FRESH": {
"ticker": "FRESH",
"expires_at": _dt(2026, 5, 18, 10, 30, tzinfo=_kst).isoformat(),
"cycle_id": 1,
},
}
monkeypatch.setattr(pull_worker, "datetime", FrozenDT)
monkeypatch.setattr(pull_worker, "_is_market_day", lambda n: True)
monkeypatch.setattr(pull_worker, "_is_polling_window", lambda n: True)
monkeypatch.setattr(pull_worker, "_next_interval", lambda n: 0.01)
monkeypatch.setattr(pull_worker, "_run_polling_cycle", AsyncMock())
monkeypatch.setattr(pull_worker, "update_minute_momentum_for_all", lambda s: None)
monkeypatch.setattr(pull_worker, "_is_post_close_trigger", lambda *a, **k: False)
shutdown = _asyncio.Event()
async def stop_soon():
await _asyncio.sleep(0.05)
shutdown.set()
_asyncio.create_task(stop_soon())
await pull_worker.poll_loop(
client=MagicMock(),
state=state,
shutdown=shutdown,
kis_client=MagicMock(),
chronos=MagicMock(),
dedup=None,
settings=None,
)
assert "OLD" not in state.signals
assert "FRESH" in state.signals