feat(signal_v2-phase3a): pull_worker KIS minute cycle + WS callback

_run_kis_minute_cycle: portfolio + screener union 종목 분봉 fetch +
screener-only 종목 호가 REST fetch. WebSocket callback factory
(make_asking_price_callback).

poll_loop / _run_polling_cycle 에 kis_client optional param 추가
(Phase 5 까지 None 일 때도 정상 동작).

2 new tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 05:19:24 +09:00
parent 163c9fb690
commit 3ebe95ba29
2 changed files with 128 additions and 4 deletions

View File

@@ -2,8 +2,10 @@
from __future__ import annotations
import asyncio
import logging
from collections import deque
from datetime import datetime
from signal_v2.kis_client import KISClient
from signal_v2.scheduler import (
KST, _is_market_day, _is_polling_window, _next_interval,
)
@@ -14,7 +16,8 @@ logger = logging.getLogger(__name__)
async def poll_loop(
client: StockClient, state: PollState, shutdown: asyncio.Event
client: StockClient, state: PollState, shutdown: asyncio.Event,
kis_client: KISClient | None = None,
) -> None:
"""FastAPI lifespan 에서 asyncio.create_task 로 시작."""
logger.info("poll_loop started")
@@ -22,7 +25,7 @@ async def poll_loop(
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
try:
await _run_polling_cycle(client, state)
await _run_polling_cycle(client, state, kis_client=kis_client)
except Exception:
logger.exception("poll cycle failed")
interval = _next_interval(now)
@@ -34,8 +37,11 @@ async def poll_loop(
logger.info("poll_loop ended")
async def _run_polling_cycle(client: StockClient, state: PollState) -> None:
"""3 endpoint 병렬 fetch + state 갱신."""
async def _run_polling_cycle(
client: StockClient, state: PollState,
kis_client: KISClient | None = None,
) -> None:
"""기존 3 endpoint (stock) + KIS 분봉 fetch."""
portfolio, sentiment, screener = await asyncio.gather(
client.get_portfolio(),
client.get_news_sentiment(),
@@ -56,3 +62,66 @@ async def _run_polling_cycle(client: StockClient, state: PollState) -> None:
else:
state.fetch_errors[name] = state.fetch_errors.get(name, 0) + 1
logger.warning("fetch %s failed: %r", name, result)
# KIS 분봉 + 호가 (kis_client 주어졌을 때만)
if kis_client is not None:
try:
await _run_kis_minute_cycle(kis_client, state)
except Exception:
logger.exception("kis minute cycle failed")
async def _run_kis_minute_cycle(kis_client: KISClient, state: PollState) -> None:
"""KIS 분봉 + 호가 fetch + state 갱신.
- 분봉: portfolio + screener Top-N union 종목 모두
- 호가 (REST): screener-only 종목 (portfolio 는 WebSocket 으로 들어옴)
"""
portfolio_tickers = _portfolio_tickers(state)
screener_tickers = _screener_tickers(state)
all_tickers = list(set(portfolio_tickers) | set(screener_tickers))
# 분봉 fetch (병렬)
minute_results = await asyncio.gather(*[
kis_client.get_minute_ohlcv(t) for t in all_tickers
], return_exceptions=True)
now_iso = datetime.now(KST).isoformat()
for ticker, result in zip(all_tickers, minute_results):
if isinstance(result, list):
buf = state.minute_bars.setdefault(ticker, deque(maxlen=60))
buf.extend(result)
state.last_updated[f"minute_bars/{ticker}"] = now_iso
else:
state.fetch_errors[f"minute_bars/{ticker}"] = (
state.fetch_errors.get(f"minute_bars/{ticker}", 0) + 1
)
# 호가 fetch (REST) — screener-only
screener_only = list(set(screener_tickers) - set(portfolio_tickers))
asking_results = await asyncio.gather(*[
kis_client.get_asking_price(t) for t in screener_only
], return_exceptions=True)
for ticker, result in zip(screener_only, asking_results):
if isinstance(result, dict):
state.asking_price[ticker] = result
state.last_updated[f"asking_price/{ticker}"] = now_iso
def make_asking_price_callback(state: PollState):
"""KIS WebSocket on_asking_price callback factory."""
def _cb(ticker: str, data: dict) -> None:
state.asking_price[ticker] = data
state.last_updated[f"asking_price/{ticker}"] = datetime.now(KST).isoformat()
return _cb
def _portfolio_tickers(state: PollState) -> list[str]:
if state.portfolio is None:
return []
return [h["ticker"] for h in state.portfolio.get("holdings", []) if "ticker" in h]
def _screener_tickers(state: PollState) -> list[str]:
if state.screener_preview is None:
return []
return [i["ticker"] for i in state.screener_preview.get("items", []) if "ticker" in i]