From bea27a75cf70ad0bbe130dec21e1cbc7208d58b4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 19:36:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(ai=5Ftrade):=20post-close=20trigger?= =?UTF-8?q?=EB=A5=BC=20=EC=83=81=ED=83=9C=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(F3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 리뷰 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) --- ai_trade/pull_worker.py | 9 ++++-- ai_trade/scheduler.py | 17 ++++++++--- ai_trade/tests/test_pull_worker.py | 48 ++++++++++++++++++++++++++++++ ai_trade/tests/test_scheduler.py | 38 +++++++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/ai_trade/pull_worker.py b/ai_trade/pull_worker.py index 3f94217..147bcb1 100644 --- a/ai_trade/pull_worker.py +++ b/ai_trade/pull_worker.py @@ -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 diff --git a/ai_trade/scheduler.py b/ai_trade/scheduler.py index dcece00..c6f9c29 100644 --- a/ai_trade/scheduler.py +++ b/ai_trade/scheduler.py @@ -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: diff --git a/ai_trade/tests/test_pull_worker.py b/ai_trade/tests/test_pull_worker.py index 6f9ae5c..de850af 100644 --- a/ai_trade/tests/test_pull_worker.py +++ b/ai_trade/tests/test_pull_worker.py @@ -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 회귀)" diff --git a/ai_trade/tests/test_scheduler.py b/ai_trade/tests/test_scheduler.py index f403afe..f135f4d 100644 --- a/ai_trade/tests/test_scheduler.py +++ b/ai_trade/tests/test_scheduler.py @@ -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