feat(ai_trade): emit signal에 cycle_id + expires_at 부착 (F5 part 3)

- generate_signals 진입에서 state.signal_cycle_id += 1 (emit 여부 무관 증가)
- _build_buy_signal/_build_sell_signal에 cycle_id + expires_at 필드 추가
- expires_at = as_of + settings.signal_ttl_seconds (default 300s)
- 매수/매도 양쪽 로그에 cycle=N 추가

기존 test_poll_loop_calls_generate_signals_after_cycle의 settings MagicMock에
signal_ttl_seconds=300 명시 (timedelta가 MagicMock 받으면 TypeError).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:59:35 +09:00
parent 94a034ef38
commit e4d02b8059
3 changed files with 77 additions and 13 deletions

View File

@@ -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,
}