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:
@@ -4,7 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,7 +20,12 @@ MOMENTUM_SCORES = {
|
|||||||
|
|
||||||
|
|
||||||
def generate_signals(state, dedup, settings) -> None:
|
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_sell_signals(state, dedup, settings)
|
||||||
_evaluate_buy_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):
|
if dedup.is_recent(ticker, "buy", within_hours=24):
|
||||||
logger.debug("buy %s skipped: dedup 24h", ticker)
|
logger.debug("buy %s skipped: dedup 24h", ticker)
|
||||||
continue
|
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)
|
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]]:
|
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
|
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]
|
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 {
|
return {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -107,7 +116,9 @@ def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidenc
|
|||||||
"avg_price": None,
|
"avg_price": None,
|
||||||
"pnl_pct": None,
|
"pnl_pct": None,
|
||||||
"context": _build_context(state, ticker, rank),
|
"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
|
continue
|
||||||
state.signals[ticker] = sell
|
state.signals[ticker] = sell
|
||||||
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
|
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"],
|
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:
|
def _try_stop_loss(state, holding: dict, settings) -> dict | None:
|
||||||
pnl = holding.get("pnl_pct")
|
pnl = holding.get("pnl_pct")
|
||||||
if pnl is None or pnl >= settings.stop_loss_pct:
|
if pnl is None or pnl >= settings.stop_loss_pct:
|
||||||
return None
|
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:
|
def _try_take_profit(state, holding: dict, settings) -> dict | None:
|
||||||
pnl = holding.get("pnl_pct")
|
pnl = holding.get("pnl_pct")
|
||||||
if pnl is None or pnl <= settings.take_profit_pct:
|
if pnl is None or pnl <= settings.take_profit_pct:
|
||||||
return None
|
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:
|
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
|
confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2
|
||||||
if confidence <= settings.confidence_threshold:
|
if confidence <= settings.confidence_threshold:
|
||||||
return None
|
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"]
|
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 {
|
return {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"name": holding.get("name", 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"),
|
"avg_price": holding.get("avg_price"),
|
||||||
"pnl_pct": holding.get("pnl_pct"),
|
"pnl_pct": holding.get("pnl_pct"),
|
||||||
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
|
|||||||
settings.asking_bid_ratio_threshold = 0.6
|
settings.asking_bid_ratio_threshold = 0.6
|
||||||
settings.confidence_threshold = 0.7
|
settings.confidence_threshold = 0.7
|
||||||
settings.min_momentum_for_buy = "strong_up"
|
settings.min_momentum_for_buy = "strong_up"
|
||||||
|
settings.signal_ttl_seconds = 300
|
||||||
|
|
||||||
generate_signals(state, dedup, settings)
|
generate_signals(state, dedup, settings)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def _settings(**overrides):
|
|||||||
asking_bid_ratio_threshold=0.6,
|
asking_bid_ratio_threshold=0.6,
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
min_momentum_for_buy="strong_up",
|
min_momentum_for_buy="strong_up",
|
||||||
|
signal_ttl_seconds=300,
|
||||||
)
|
)
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
m = MagicMock()
|
m = MagicMock()
|
||||||
@@ -170,3 +171,48 @@ def test_sell_signal_triggers_on_anomaly_path(dedup_mock):
|
|||||||
assert sig["action"] == "sell"
|
assert sig["action"] == "sell"
|
||||||
assert sig["context"]["sell_reason"] == "anomaly"
|
assert sig["context"]["sell_reason"] == "anomaly"
|
||||||
assert sig["confidence_webai"] > 0.7
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user