From 2a11d05f4af6312d28f70d8b4017bb7a7e42d5cd Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 19:54:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai=5Ftrade):=20state.signals=EC=97=90=20ex?= =?UTF-8?q?pires=5Fat=20+=20cycle=5Fid=20lifecycle=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(F5=20part=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 리뷰 F5 — Phase 5 consumer(agent-office /signal) prereq: PollState.signal_cycle_id (process auto-increment) + get_active_signals(now) + purge_expired_signals(now) helper. expires_at 없는 legacy signal은 expired 취급. Co-Authored-By: Claude Opus 4.7 (1M context) --- ai_trade/state.py | 37 ++++++++++++++ .../tests/test_state_signals_lifecycle.py | 50 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 ai_trade/tests/test_state_signals_lifecycle.py diff --git a/ai_trade/state.py b/ai_trade/state.py index 6fc4340..018964b 100644 --- a/ai_trade/state.py +++ b/ai_trade/state.py @@ -1,6 +1,7 @@ """PollState — process-wide singleton.""" from collections import deque from dataclasses import dataclass, field +from datetime import datetime @dataclass @@ -15,8 +16,44 @@ class PollState: chronos_predictions: dict[str, dict] = field(default_factory=dict) minute_momentum: dict[str, str] = field(default_factory=dict) signals: dict[str, dict] = field(default_factory=dict) + # F5 lifecycle + signal_cycle_id: int = 0 last_updated: dict[str, str] = field(default_factory=dict) fetch_errors: dict[str, int] = field(default_factory=dict) + def get_active_signals(self, now: datetime) -> list[dict]: + """expires_at > now 인 신호만 반환. expires_at 없거나 파싱 실패는 expired 취급.""" + active: list[dict] = [] + for sig in self.signals.values(): + expires_at = sig.get("expires_at") + if not expires_at: + continue + try: + exp_dt = datetime.fromisoformat(expires_at) + except ValueError: + continue + if exp_dt > now: + active.append(sig) + return active + + def purge_expired_signals(self, now: datetime) -> int: + """만료된 signal 제거. expires_at 없거나 파싱 실패도 제거. 제거 개수 반환.""" + to_drop = [] + for ticker, sig in self.signals.items(): + expires_at = sig.get("expires_at") + if not expires_at: + to_drop.append(ticker) + continue + try: + exp_dt = datetime.fromisoformat(expires_at) + except ValueError: + to_drop.append(ticker) + continue + if exp_dt <= now: + to_drop.append(ticker) + for t in to_drop: + del self.signals[t] + return len(to_drop) + state = PollState() diff --git a/ai_trade/tests/test_state_signals_lifecycle.py b/ai_trade/tests/test_state_signals_lifecycle.py new file mode 100644 index 0000000..ba785b9 --- /dev/null +++ b/ai_trade/tests/test_state_signals_lifecycle.py @@ -0,0 +1,50 @@ +"""F5 — state.signals lifecycle (expires_at + cycle_id).""" +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from ai_trade.state import PollState + +KST = ZoneInfo("Asia/Seoul") + + +def test_initial_signal_cycle_id_is_zero(): + state = PollState() + assert state.signal_cycle_id == 0 + + +def test_get_active_signals_excludes_expired(): + state = PollState() + now = datetime(2026, 5, 25, 10, 0, tzinfo=KST) + future = (now + timedelta(seconds=300)).isoformat() + past = (now - timedelta(seconds=60)).isoformat() + state.signals = { + "A": {"ticker": "A", "expires_at": future, "cycle_id": 1, "action": "buy"}, + "B": {"ticker": "B", "expires_at": past, "cycle_id": 1, "action": "buy"}, + } + active = state.get_active_signals(now) + tickers = [s["ticker"] for s in active] + assert "A" in tickers + assert "B" not in tickers + + +def test_get_active_signals_treats_missing_expires_as_expired(): + """expires_at 없는 legacy 신호는 expired로 간주.""" + state = PollState() + now = datetime(2026, 5, 25, 10, 0, tzinfo=KST) + state.signals = {"C": {"ticker": "C", "action": "buy"}} + assert state.get_active_signals(now) == [] + + +def test_purge_expired_signals_removes_expired(): + state = PollState() + now = datetime(2026, 5, 25, 10, 0, tzinfo=KST) + future = (now + timedelta(seconds=300)).isoformat() + past = (now - timedelta(seconds=60)).isoformat() + state.signals = { + "A": {"ticker": "A", "expires_at": future, "cycle_id": 1}, + "B": {"ticker": "B", "expires_at": past, "cycle_id": 1}, + } + removed = state.purge_expired_signals(now) + assert "A" in state.signals + assert "B" not in state.signals + assert removed == 1