feat(ai_trade): state.signals에 expires_at + cycle_id lifecycle 추가 (F5 part 1)
코드 리뷰 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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"""PollState — process-wide singleton."""
|
"""PollState — process-wide singleton."""
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -15,8 +16,44 @@ class PollState:
|
|||||||
chronos_predictions: dict[str, dict] = field(default_factory=dict)
|
chronos_predictions: dict[str, dict] = field(default_factory=dict)
|
||||||
minute_momentum: dict[str, str] = field(default_factory=dict)
|
minute_momentum: dict[str, str] = field(default_factory=dict)
|
||||||
signals: dict[str, dict] = 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)
|
last_updated: dict[str, str] = field(default_factory=dict)
|
||||||
fetch_errors: dict[str, int] = 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()
|
state = PollState()
|
||||||
|
|||||||
50
ai_trade/tests/test_state_signals_lifecycle.py
Normal file
50
ai_trade/tests/test_state_signals_lifecycle.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user