feat: KIS 실시간 스트림 서비스, WS 구독 프로토콜, MCP 리스크 승인 연동

This commit is contained in:
2026-02-24 18:16:20 +09:00
parent 8c9b4abc72
commit 24f3082693
3 changed files with 510 additions and 53 deletions

View File

@@ -76,6 +76,56 @@ async def batch_analysis_scheduler():
raise
async def _call_mcp_risk_assessment(rec: dict) -> tuple[bool, str]:
"""MCP RISK_ASSESSMENT 호출.
반환: (approve: bool, reason: str)
MCP 서버 장애 / 타임아웃 시 기본 approve=True (fail-open)로 처리.
"""
import httpx
# 추천 요약 데이터 구성
recommendations = rec.get("recommendations_by_rule", {})
summary = {
"recommendation_count": sum(len(v) for v in recommendations.values()),
"rules": list(recommendations.keys()),
"top_stocks": [
item.get("stock_code") or item.get("code", "")
for rule_items in recommendations.values()
for item in (rule_items[:3] if isinstance(rule_items, list) else [])
][:10],
}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json={
"analysis_type": "RISK_ASSESSMENT",
"data": summary,
},
timeout=15.0,
)
if resp.status_code == 200:
result = resp.json()
# MCP 서버 응답 형식: {"decision": "approve"|"deny", "reason": "..."}
# 또는 {"result": {"decision": ..., "reason": ...}}
payload = result.get("result", result)
decision = str(payload.get("decision", "approve")).lower()
reason = str(payload.get("reason", ""))
if "deny" in decision or "reject" in decision:
return False, reason
return True, reason
else:
logger.warning(f"⚠️ MCP RISK_ASSESSMENT HTTP {resp.status_code} — fail-open")
return True, "mcp_http_error_fail_open"
except httpx.TimeoutException:
logger.warning("⚠️ MCP RISK_ASSESSMENT 타임아웃 — fail-open")
return True, "mcp_timeout_fail_open"
except Exception as e:
logger.warning(f"⚠️ MCP RISK_ASSESSMENT 오류: {e} — fail-open")
return True, f"mcp_error_fail_open: {e}"
async def auto_trade_scheduler():
"""1분 간격 자동매매 스케줄러.
DB 캐시에서 추천 종목을 읽어 손절/익절 및 신규 매수를 실행한다.
@@ -113,8 +163,27 @@ async def auto_trade_scheduler():
rec = await get_rule_based_recommendations_from_db(session)
if rec and rec.get("recommendations_by_rule"):
logger.info(f"📊 Found recommendations, executing auto-trade")
await execute_auto_trade_background(rec, session)
# ── MCP RISK_ASSESSMENT 승인 체크 ──────────────────────
approve, reason = await _call_mcp_risk_assessment(rec)
if not approve:
logger.warning(
f"🛑 MCP RISK_ASSESSMENT DENY → 주문 스킵. 사유: {reason}"
)
# deny 사유 로그 저장 (선택적)
try:
from app.services.db_worker import enqueue_db_write
await enqueue_db_write({
"action": "log_mcp_deny",
"reason": reason,
})
except Exception:
pass
else:
if reason:
logger.info(f"✅ MCP RISK_ASSESSMENT APPROVE. 참고: {reason}")
logger.info(f"📊 Found recommendations, executing auto-trade")
await execute_auto_trade_background(rec, session)
# ────────────────────────────────────────────────────────
else:
logger.info("⏭️ Auto-trade skipped: no recommendations in DB")
else:
@@ -324,11 +393,21 @@ async def lifespan(app: FastAPI):
app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler())
app.state.day_trade_task = asyncio.create_task(day_trade_scheduler())
# KIS 실시간 스트림 시작
from app.services.kis_realtime_stream import run_kis_realtime_stream
app.state.kis_stream_task = asyncio.create_task(run_kis_realtime_stream())
logger.info("📡 KIS 실시간 스트림 태스크 시작")
yield
# 종료 시 정리 작업
# 스케줄러 중단
for task_attr in ("auto_trade_task", "batch_analysis_task", "day_trade_task", "db_worker_task"):
for task_attr in (
"auto_trade_task",
"batch_analysis_task",
"day_trade_task",
"db_worker_task",
"kis_stream_task", # ← 추가
):
try:
task = getattr(app.state, task_attr, None)
if task:
@@ -406,24 +485,97 @@ async def health_check():
@app.websocket("/ws/{client_id}")
async def websocket_endpoint(websocket: WebSocket, client_id: str):
"""WebSocket 연결 엔드포인트"""
"""WebSocket 연결 엔드포인트.
클라이언트→서버 지원 프로토콜:
{"type": "ping"}
→ {"type": "pong"}
{"type": "subscribe", "stock_codes": ["005930", ...]}
→ {"type": "ack", "action": "subscribe",
"stock_codes": [...], "subscriptions": [현재 전체 목록]}
{"type": "unsubscribe", "stock_codes": ["005930", ...]}
→ {"type": "ack", "action": "unsubscribe",
"stock_codes": [...], "subscriptions": [현재 전체 목록]}
{"type": "set_subscriptions", "stock_codes": ["005930", ...]}
→ {"type": "ack", "action": "set_subscriptions",
"stock_codes": [...], "subscriptions": [현재 전체 목록]}
그 외 메시지 → echo 응답
"""
await websocket_manager.connect(websocket, client_id)
try:
while True:
data = await websocket.receive_text()
# ping/pong 하트비트 처리
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await websocket_manager.send_personal_message(
json.dumps({"type": "pong"}), client_id
)
continue
except (json.JSONDecodeError, AttributeError):
pass
await websocket_manager.send_personal_message(
f"Echo: {data}", client_id
)
continue
msg_type = msg.get("type", "")
# ── ping / pong ──────────────────────────────────────────────
if msg_type == "ping":
await websocket_manager.send_personal_message(
json.dumps({"type": "pong"}), client_id
)
continue
# ── subscribe ───────────────────────────────────────────────
if msg_type == "subscribe":
codes = [str(c) for c in msg.get("stock_codes", [])]
websocket_manager.subscribe(client_id, codes)
await websocket_manager.send_personal_message(
json.dumps({
"type": "ack",
"action": "subscribe",
"stock_codes": codes,
"subscriptions": websocket_manager.get_subscriptions(client_id),
}),
client_id,
)
continue
# ── unsubscribe ──────────────────────────────────────────────
if msg_type == "unsubscribe":
codes = [str(c) for c in msg.get("stock_codes", [])]
websocket_manager.unsubscribe(client_id, codes)
await websocket_manager.send_personal_message(
json.dumps({
"type": "ack",
"action": "unsubscribe",
"stock_codes": codes,
"subscriptions": websocket_manager.get_subscriptions(client_id),
}),
client_id,
)
continue
# ── set_subscriptions ────────────────────────────────────────
if msg_type == "set_subscriptions":
codes = [str(c) for c in msg.get("stock_codes", [])]
websocket_manager.set_subscriptions(client_id, codes)
await websocket_manager.send_personal_message(
json.dumps({
"type": "ack",
"action": "set_subscriptions",
"stock_codes": codes,
"subscriptions": websocket_manager.get_subscriptions(client_id),
}),
client_id,
)
continue
# ── 기타 메시지 → echo ───────────────────────────────────────
await websocket_manager.send_personal_message(
f"Echo: {data}", client_id
)
except WebSocketDisconnect:
websocket_manager.disconnect(client_id)
logger.info(f"Client {client_id} disconnected")