diff --git a/ai_trade/signal_generator.py b/ai_trade/signal_generator.py index a37ce9a..db43938 100644 --- a/ai_trade/signal_generator.py +++ b/ai_trade/signal_generator.py @@ -4,7 +4,7 @@ """ from __future__ import annotations import logging -from datetime import datetime +from datetime import datetime, timedelta from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) @@ -20,7 +20,12 @@ MOMENTUM_SCORES = { def generate_signals(state, dedup, settings) -> None: - """Phase 4 entry — state-mutating. Evaluation order: sell first (priority), then buy. A ticker receiving a sell signal in this cycle is excluded from buy evaluation to avoid silent overwrite.""" + """Phase 4 entry — state-mutating. F5: cycle_id += 1 (호출마다, emit 여부 무관). + + Evaluation order: sell first (priority), then buy. A ticker receiving a sell + signal in this cycle is excluded from buy evaluation to avoid silent overwrite. + """ + state.signal_cycle_id += 1 _evaluate_sell_signals(state, dedup, settings) _evaluate_buy_signals(state, dedup, settings) @@ -45,9 +50,10 @@ def _evaluate_buy_signals(state, dedup, settings) -> None: if dedup.is_recent(ticker, "buy", within_hours=24): logger.debug("buy %s skipped: dedup 24h", ticker) continue - state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence) + state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence, settings) dedup.record(ticker, "buy", confidence=confidence) - logger.info("signal emit %s buy conf=%.3f rank=%s", ticker, confidence, rank) + logger.info("signal emit %s buy conf=%.3f rank=%s cycle=%d", + ticker, confidence, rank, state.signal_cycle_id) def _buy_candidates(state) -> list[tuple[str, str, int | None]]: @@ -96,8 +102,11 @@ def _compute_buy_confidence(state, ticker: str, rank: int | None) -> float: return chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2 -def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict: +def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float, settings) -> dict: ap = state.asking_price[ticker] + as_of_dt = datetime.now(KST) + ttl = getattr(settings, "signal_ttl_seconds", 300) + expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat() return { "ticker": ticker, "name": name, @@ -107,7 +116,9 @@ def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidenc "avg_price": None, "pnl_pct": None, "context": _build_context(state, ticker, rank), - "as_of": datetime.now(KST).isoformat(), + "as_of": as_of_dt.isoformat(), + "cycle_id": state.signal_cycle_id, + "expires_at": expires_at, } @@ -132,23 +143,24 @@ def _evaluate_sell_signals(state, dedup, settings) -> None: continue state.signals[ticker] = sell dedup.record(ticker, "sell", confidence=sell["confidence_webai"]) - logger.info("signal emit %s sell conf=%.3f reason=%s", + logger.info("signal emit %s sell conf=%.3f reason=%s cycle=%d", ticker, sell["confidence_webai"], - sell.get("context", {}).get("sell_reason")) + sell.get("context", {}).get("sell_reason"), + state.signal_cycle_id) def _try_stop_loss(state, holding: dict, settings) -> dict | None: pnl = holding.get("pnl_pct") if pnl is None or pnl >= settings.stop_loss_pct: return None - return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss") + return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss", settings=settings) def _try_take_profit(state, holding: dict, settings) -> dict | None: pnl = holding.get("pnl_pct") if pnl is None or pnl <= settings.take_profit_pct: return None - return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit") + return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit", settings=settings) def _try_anomaly(state, holding: dict, settings) -> dict | None: @@ -168,11 +180,14 @@ def _try_anomaly(state, holding: dict, settings) -> dict | None: confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2 if confidence <= settings.confidence_threshold: return None - return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly") + return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly", settings=settings) -def _build_sell_signal(state, holding: dict, confidence: float, reason: str) -> dict: +def _build_sell_signal(state, holding: dict, confidence: float, reason: str, settings=None) -> dict: ticker = holding["ticker"] + as_of_dt = datetime.now(KST) + ttl = getattr(settings, "signal_ttl_seconds", 300) if settings else 300 + expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat() return { "ticker": ticker, "name": holding.get("name", ticker), @@ -182,7 +197,9 @@ def _build_sell_signal(state, holding: dict, confidence: float, reason: str) -> "avg_price": holding.get("avg_price"), "pnl_pct": holding.get("pnl_pct"), "context": _build_context(state, ticker, rank=None, sell_reason=reason), - "as_of": datetime.now(KST).isoformat(), + "as_of": as_of_dt.isoformat(), + "cycle_id": state.signal_cycle_id, + "expires_at": expires_at, } diff --git a/ai_trade/tests/test_pull_worker.py b/ai_trade/tests/test_pull_worker.py index de850af..27d49d4 100644 --- a/ai_trade/tests/test_pull_worker.py +++ b/ai_trade/tests/test_pull_worker.py @@ -122,6 +122,7 @@ def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch): settings.asking_bid_ratio_threshold = 0.6 settings.confidence_threshold = 0.7 settings.min_momentum_for_buy = "strong_up" + settings.signal_ttl_seconds = 300 generate_signals(state, dedup, settings) diff --git a/ai_trade/tests/test_signal_generator.py b/ai_trade/tests/test_signal_generator.py index 663efd7..62e9974 100644 --- a/ai_trade/tests/test_signal_generator.py +++ b/ai_trade/tests/test_signal_generator.py @@ -16,6 +16,7 @@ def _settings(**overrides): asking_bid_ratio_threshold=0.6, confidence_threshold=0.7, min_momentum_for_buy="strong_up", + signal_ttl_seconds=300, ) defaults.update(overrides) m = MagicMock() @@ -170,3 +171,48 @@ def test_sell_signal_triggers_on_anomaly_path(dedup_mock): assert sig["action"] == "sell" assert sig["context"]["sell_reason"] == "anomaly" assert sig["confidence_webai"] > 0.7 + + +# ----- F5: cycle_id + expires_at 부착 ----- + +def test_emit_attaches_cycle_id_and_expires_at(dedup_mock): + """F5 — emit signal에 cycle_id (state.signal_cycle_id) + expires_at 부착.""" + from datetime import datetime, timedelta + from zoneinfo import ZoneInfo + _kst = ZoneInfo("Asia/Seoul") + + state = _make_state_with_buy_candidate() + before = datetime.now(_kst) + generate_signals(state, dedup_mock, _settings(signal_ttl_seconds=300)) + after = datetime.now(_kst) + + sig = state.signals["005930"] + assert sig["cycle_id"] == 1 + assert "expires_at" in sig + exp_dt = datetime.fromisoformat(sig["expires_at"]) + assert before + timedelta(seconds=295) < exp_dt < after + timedelta(seconds=305) + + +def test_cycle_id_increments_each_call(dedup_mock): + """F5 — generate_signals 호출마다 cycle_id += 1 (emit 여부 무관).""" + state = _make_state_with_buy_candidate() + generate_signals(state, dedup_mock, _settings()) + assert state.signal_cycle_id == 1 + # 2번째 호출 — dedup이 막아도 cycle_id는 증가 + dedup_mock.is_recent.return_value = True + generate_signals(state, dedup_mock, _settings()) + assert state.signal_cycle_id == 2 + + +def test_sell_signal_also_carries_cycle_id_and_expires_at(dedup_mock): + """F5 — sell signal도 동일하게 부착.""" + from datetime import datetime + state = _make_state_with_holding(pnl_pct=-0.08, current_price=68000) + generate_signals(state, dedup_mock, _settings(signal_ttl_seconds=120)) + + assert "005930" in state.signals + sig = state.signals["005930"] + assert sig["action"] == "sell" + assert sig["cycle_id"] == 1 + # parse expires_at as ISO — must succeed + datetime.fromisoformat(sig["expires_at"])