feat(signal_v2-phase3a): main.py lifespan integrates KIS client + WS

AppContext extended with kis_client + kis_ws. lifespan:
- If KIS_APP_KEY set: create KISClient + KISWebSocket, fetch portfolio,
  subscribe WebSocket H0STASP0 for holdings.
- If unset: WARNING log, signal_v2 still serves /health (no KIS data).
- Shutdown closes kis_ws → kis_client → stock client in order.

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

View File

@@ -8,7 +8,9 @@ from fastapi import FastAPI
from signal_v2 import state as state_mod from signal_v2 import state as state_mod
from signal_v2.config import get_settings from signal_v2.config import get_settings
from signal_v2.pull_worker import poll_loop from signal_v2.kis_client import KISClient
from signal_v2.kis_websocket import KISWebSocket
from signal_v2.pull_worker import poll_loop, make_asking_price_callback
from signal_v2.rate_limit import SignalDedup from signal_v2.rate_limit import SignalDedup
from signal_v2.stock_client import StockClient from signal_v2.stock_client import StockClient
@@ -20,6 +22,8 @@ class AppContext:
dedup: SignalDedup | None = None dedup: SignalDedup | None = None
shutdown: asyncio.Event | None = None shutdown: asyncio.Event | None = None
poll_task: asyncio.Task | None = None poll_task: asyncio.Task | None = None
kis_client: KISClient | None = None
kis_ws: KISWebSocket | None = None
_ctx = AppContext() _ctx = AppContext()
@@ -32,12 +36,43 @@ async def lifespan(app: FastAPI):
logger.warning( logger.warning(
"WEBAI_API_KEY not configured — stock API calls will fail with 401" "WEBAI_API_KEY not configured — stock API calls will fail with 401"
) )
if not settings.kis_app_key:
logger.warning(
"KIS_APP_KEY not configured — KIS REST/WebSocket disabled"
)
_ctx.client = StockClient(settings.stock_api_url, settings.webai_api_key) _ctx.client = StockClient(settings.stock_api_url, settings.webai_api_key)
_ctx.dedup = SignalDedup(settings.db_path) _ctx.dedup = SignalDedup(settings.db_path)
_ctx.shutdown = asyncio.Event() _ctx.shutdown = asyncio.Event()
# KIS only if app_key configured
if settings.kis_app_key:
_ctx.kis_client = KISClient(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
account=settings.kis_account,
is_virtual=settings.kis_is_virtual,
v1_token_path=settings.v1_token_path,
)
_ctx.kis_ws = KISWebSocket(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
is_virtual=settings.kis_is_virtual,
)
# Subscribe portfolio holdings (if any)
try:
portfolio = await _ctx.client.get_portfolio()
tickers = [h["ticker"] for h in portfolio.get("holdings", []) if "ticker" in h]
cb = make_asking_price_callback(state_mod.state)
await _ctx.kis_ws.start(tickers, cb)
except Exception:
logger.exception("KIS WebSocket startup failed — continuing without realtime asking_price")
_ctx.poll_task = asyncio.create_task( _ctx.poll_task = asyncio.create_task(
poll_loop(_ctx.client, state_mod.state, _ctx.shutdown) poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
)
) )
yield yield
@@ -54,6 +89,10 @@ async def lifespan(app: FastAPI):
await _ctx.poll_task await _ctx.poll_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if _ctx.kis_ws is not None:
await _ctx.kis_ws.close()
if _ctx.kis_client is not None:
await _ctx.kis_client.close()
if _ctx.client is not None: if _ctx.client is not None:
await _ctx.client.close() await _ctx.client.close()

View File

@@ -34,3 +34,20 @@ def test_startup_warns_if_webai_api_key_missing(monkeypatch, caplog):
with TestClient(main_mod.app) as client: with TestClient(main_mod.app) as client:
client.get("/health") client.get("/health")
assert any("WEBAI_API_KEY" in rec.message for rec in caplog.records) assert any("WEBAI_API_KEY" in rec.message for rec in caplog.records)
def test_startup_warns_if_kis_app_key_missing(monkeypatch, caplog):
"""KIS_APP_KEY 미설정 시 startup WARNING (KIS 호출 disabled)."""
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
monkeypatch.delenv("KIS_APP_KEY", raising=False)
import importlib
from signal_v2 import config as cfg
importlib.reload(cfg)
from signal_v2 import main as main_mod
importlib.reload(main_mod)
with caplog.at_level(logging.WARNING, logger="signal_v2.main"):
with TestClient(main_mod.app) as client:
client.get("/health")
assert any("KIS_APP_KEY" in rec.message for rec in caplog.records)