fix(ai_trade): post-close trigger를 상태기반으로 변경 (F3)
코드 리뷰 F3: _is_post_close_trigger가 16:00:00-16:00:59 1분 윈도우만 true. 5분 sleep + 비결정적 cycle 시작시각 조합으로 영영 못 잡는 경우 존재 (예: cycle이 15:31에 시작하면 15:36, 15:41 ... 16:01에 깸). "오늘 아직 post-close 안 돌렸고 현재 시각 ≥ 16:00" 상태기반으로 변경. poll_loop가 last_post_close_date 변수로 일 1회 실행 보장. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ async def poll_loop(
|
||||
) -> None:
|
||||
"""FastAPI lifespan 에서 asyncio.create_task 로 시작."""
|
||||
logger.info("poll_loop started")
|
||||
last_post_close_date = None # F3: state-based post-close trigger
|
||||
while not shutdown.is_set():
|
||||
now = datetime.now(KST)
|
||||
if _is_market_day(now) and _is_polling_window(now):
|
||||
@@ -36,10 +37,14 @@ async def poll_loop(
|
||||
update_minute_momentum_for_all(state)
|
||||
except Exception:
|
||||
logger.exception("minute momentum update failed")
|
||||
# Post-close trigger (16:00 KST)
|
||||
if _is_post_close_trigger(now) and chronos is not None and kis_client is not None:
|
||||
# Post-close trigger (F3: 상태기반 — 16:00 이후 + 오늘 미실행)
|
||||
if (
|
||||
_is_post_close_trigger(now, last_post_close_date)
|
||||
and chronos is not None and kis_client is not None
|
||||
):
|
||||
try:
|
||||
await _run_post_close_cycle(kis_client, chronos, state)
|
||||
last_post_close_date = now.date()
|
||||
except Exception:
|
||||
logger.exception("post-close cycle failed")
|
||||
# Phase 4: generate signals
|
||||
|
||||
@@ -76,12 +76,21 @@ def _seconds_until_nxt_or_market_open(now: datetime) -> float:
|
||||
return 86400.0
|
||||
|
||||
|
||||
def _is_post_close_trigger(now: datetime) -> bool:
|
||||
"""16:00 KST ±1분 (post-close cycle 트리거). 평일/영업일만."""
|
||||
def _is_post_close_trigger(now: datetime, last_post_close_date) -> bool:
|
||||
"""F3 — 16:00 KST 이후 오늘 아직 post-close cycle 안 돌렸으면 True (상태기반).
|
||||
|
||||
이전엔 16:00:00-16:00:59 1분 윈도우라 5분 sleep + 비결정적 cycle 시작시각
|
||||
조합으로 영영 못 잡는 경우 발생 (예: cycle이 15:31에 시작되면 16:01에 깸).
|
||||
|
||||
Args:
|
||||
now: 현재 KST datetime.
|
||||
last_post_close_date: 마지막 post-close 실행 영업일 date (None=미실행).
|
||||
"""
|
||||
if not _is_market_day(now):
|
||||
return False
|
||||
t = now.time()
|
||||
return time(16, 0) <= t < time(16, 1)
|
||||
if now.time() < time(16, 0):
|
||||
return False
|
||||
return last_post_close_date != now.date()
|
||||
|
||||
|
||||
def _seconds_until_next_market_open(now: datetime) -> float:
|
||||
|
||||
@@ -129,3 +129,51 @@ def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
|
||||
assert state.signals["005930"]["action"] == "sell"
|
||||
assert state.signals["005930"]["confidence_webai"] == 1.0
|
||||
dedup.record.assert_called_with("005930", "sell", confidence=1.0)
|
||||
|
||||
|
||||
async def test_post_close_fires_at_1601_when_not_yet_today(monkeypatch):
|
||||
"""F3 — 16:01에 깬 cycle도 오늘 post_close 안 돌렸으면 호출됨 (회귀 방지)."""
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
import asyncio as _asyncio
|
||||
|
||||
from ai_trade import pull_worker
|
||||
|
||||
_kst = _ZI("Asia/Seoul")
|
||||
now_at_1601 = _dt(2026, 5, 18, 16, 1, tzinfo=_kst)
|
||||
|
||||
class FrozenDateTime:
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
return now_at_1601
|
||||
|
||||
monkeypatch.setattr(pull_worker, "datetime", FrozenDateTime)
|
||||
monkeypatch.setattr(pull_worker, "_is_market_day", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_is_polling_window", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_next_interval", lambda n: 0.01)
|
||||
monkeypatch.setattr(pull_worker, "_run_polling_cycle", AsyncMock())
|
||||
monkeypatch.setattr(pull_worker, "update_minute_momentum_for_all", lambda s: None)
|
||||
post_close = AsyncMock()
|
||||
monkeypatch.setattr(pull_worker, "_run_post_close_cycle", post_close)
|
||||
|
||||
state = MagicMock()
|
||||
chronos = MagicMock()
|
||||
kis = MagicMock()
|
||||
shutdown = _asyncio.Event()
|
||||
|
||||
async def _stop_soon():
|
||||
await _asyncio.sleep(0.05)
|
||||
shutdown.set()
|
||||
|
||||
_asyncio.create_task(_stop_soon())
|
||||
await pull_worker.poll_loop(
|
||||
client=MagicMock(),
|
||||
state=state,
|
||||
shutdown=shutdown,
|
||||
kis_client=kis,
|
||||
chronos=chronos,
|
||||
dedup=None,
|
||||
settings=None,
|
||||
)
|
||||
|
||||
assert post_close.await_count >= 1, "post-close가 16:01에 호출되지 않음 (F3 회귀)"
|
||||
|
||||
@@ -79,3 +79,41 @@ def test_next_interval_dead_zone_skip():
|
||||
interval = _next_interval(now)
|
||||
# 02:00 → 04:30 = 2.5h = 9000s
|
||||
assert 9000 - 60 < interval < 9000 + 60
|
||||
|
||||
|
||||
# ----- F3 post-close 상태기반 트리거 -----
|
||||
|
||||
from datetime import date as _date # noqa: E402
|
||||
from ai_trade.scheduler import _is_post_close_trigger # noqa: E402
|
||||
|
||||
|
||||
def test_post_close_trigger_fires_at_1601_if_not_yet_today():
|
||||
"""F3 — 16:01에 깬 cycle도 오늘 아직 안 돌렸으면 trigger."""
|
||||
now = _kst(2026, 5, 18, 16, 1)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is True
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_if_already_today():
|
||||
"""F3 — 이미 오늘 돌렸으면 trigger 안 함."""
|
||||
now = _kst(2026, 5, 18, 16, 5)
|
||||
today = _date(2026, 5, 18)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=today) is False
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_before_1600():
|
||||
"""F3 — 16:00 전에는 trigger 안 함."""
|
||||
now = _kst(2026, 5, 18, 15, 59)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||
|
||||
|
||||
def test_post_close_trigger_fires_next_day_after_reset():
|
||||
"""F3 — 다음 영업일이 되면 다시 trigger."""
|
||||
now = _kst(2026, 5, 19, 16, 0)
|
||||
yesterday = _date(2026, 5, 18)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=yesterday) is True
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_on_holiday():
|
||||
"""F3 — 휴장일에는 trigger 안 함 (2026-05-05 어린이날)."""
|
||||
now = _kst(2026, 5, 5, 16, 30)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||
|
||||
Reference in New Issue
Block a user