""" FastAPI 애플리케이션 진입점 - CORS, 미들웨어 설정 - WebSocket 실시간 시세 브로드캐스트 - 라우터 등록 및 startup/shutdown 이벤트 관리 """ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import contextlib import asyncio import json import logging import time import pytz from app.core.config import settings from app.api import api_router from app.services.websocket_manager import websocket_manager from app.core.json_helpers import _dumps from app.services.db_worker import db_writer_worker from app.core.database import engine, Base, AsyncSessionLocal from app.models import models # 모델 임포트 from app.core.market_schedule import is_market_open # 로깅 설정 import os import logging.handlers from app.core.logging_config import KSTFormatter _log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) _log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' _log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs") os.makedirs(_log_dir, exist_ok=True) _log_file = os.path.join(_log_dir, "backend.log") _formatter = KSTFormatter(_log_format) _handlers: list[logging.Handler] = [ logging.StreamHandler(), logging.handlers.RotatingFileHandler( _log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" ), ] for _h in _handlers: _h.setFormatter(_formatter) logging.basicConfig(level=_log_level, handlers=_handlers) logger = logging.getLogger(__name__) def _normalize_recommendations(rec: dict) -> dict: """서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화. - get_rule_based_recommendations_realtime() → {"recommendations": [...], ...} - get_rule_based_recommendations_from_db() → {"recommendations_by_rule": {...}, ...} 둘 다 recommendations_by_rule 키를 보장한 상태로 돌려준다. Redis 캐시 데이터와 DB 데이터 모두 이 함수를 통해 정규화. 변환 실패 시 원본 rec를 그대로 반환 (런타임 에러 방지). """ if not rec: return rec or {} try: # Case 1: Already normalized (dict) if isinstance(rec.get("recommendations_by_rule"), dict): return rec # Determine the source of the recommendation list raw_list = [] if "recommendations" in rec and isinstance(rec["recommendations"], list): raw_list = rec["recommendations"] elif "recommendations_by_rule" in rec and isinstance(rec["recommendations_by_rule"], list): # This is the case that causes the bug raw_list = rec["recommendations_by_rule"] if not raw_list: # if there is no list to process, make sure the output has a dict rec["recommendations_by_rule"] = {} return rec # Group the list by rule by_rule: dict = {} for item in raw_list: rule = ( item.get("rule_name") or item.get("signal") or item.get("strategy") or "general" ) by_rule.setdefault(rule, []).append(item) # Return the original dict with the new/replaced 'recommendations_by_rule' return {**rec, "recommendations_by_rule": by_rule} except Exception as e: logger.warning(f"⚠️ _normalize_recommendations 변환 실패, 원본 반환: {e}") return rec async def batch_analysis_scheduler(): """10분 간격 배치 분석 스케줄러. 장중 10분마다 KIS API로 종목 분석 후 stock_analysis DB에 upsert. 분석 완료 후 Redis에 recommendations:latest 캐시를 저장해 auto_trade_scheduler가 DB 조회 없이 바로 매매 판단에 활용할 수 있도록 한다. 개선: - 15:30 이후 장 종료 감지 → 익일 08:50까지 자동 중단 - 분석 실패 시 exponential backoff 재시도 (최대 3회: 10s/20s/40s) """ await asyncio.sleep(15) # 부팅 안정화 (auto_trade_scheduler보다 늦게 시작) from app.api.endpoints.recommendations import get_rule_based_recommendations_realtime from app.core.redis import setex_json, TTL_RECOMMENDATIONS, TTL_ANALYSIS from datetime import timedelta as _td interval = int(getattr(settings, "BATCH_ANALYSIS_INTERVAL", 600)) # 기본 10분 _KST = pytz.timezone("Asia/Seoul") logger.info(f"📊 Batch analysis scheduler started (every {interval}s)") try: while True: now_kst = pytz.datetime.datetime.now(_KST) # 15:30 이후 → 장 종료, 익일 08:50까지 대기 if now_kst.hour > 15 or (now_kst.hour == 15 and now_kst.minute >= 30): tomorrow_start = ( _KST.localize( pytz.datetime.datetime(now_kst.year, now_kst.month, now_kst.day, 8, 50, 0) ) + _td(days=1) ) sleep_sec = max((tomorrow_start - now_kst).total_seconds(), 3600) logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개") await asyncio.sleep(sleep_sec) continue if not is_market_open(): await asyncio.sleep(1800) continue # ── exponential backoff 재시도 (최대 3회) ──────────────────────── max_retries = 3 for attempt in range(max_retries): try: suffix = f" (재시도 {attempt + 1}/{max_retries})" if attempt > 0 else "" logger.info(f"🔄 [Batch] 10-min analysis cycle starting...{suffix}") result = await get_rule_based_recommendations_realtime() count = len(result.get("recommendations", [])) if result else 0 logger.info(f"✅ [Batch] Done: {count} stocks saved/updated in stock_analysis DB") # ── Redis 캐시 업데이트 ───────────────────────────────────────── if result: # 정규화: recommendations_by_rule 키 보장 (auto_trade_scheduler 호환) normalized = _normalize_recommendations(result) ok = await setex_json( "recommendations:latest", TTL_RECOMMENDATIONS, normalized ) if ok: rec_by_rule = normalized.get('recommendations_by_rule', []) rec_count = len(rec_by_rule) if isinstance(rec_by_rule, (list, dict)) else 0 logger.info( f"💾 [Batch] Redis 캐시 갱신 완료: recommendations:latest " f"(rule 수: {rec_count}, " f"TTL: {TTL_RECOMMENDATIONS}s)" ) # ===== 개별 종목별 캐시 (병렬 처리 적용) ===== raw_list = result.get("recommendations", []) cache_tasks = [] for item in raw_list: code = item.get("stock_code") or item.get("code") if code: cache_tasks.append(setex_json(f"analysis:{code}", TTL_ANALYSIS, item)) if cache_tasks: await asyncio.gather(*cache_tasks, return_exceptions=True) logger.info(f"💾 [Batch] 개별 종목 분석 데이터 캐시 갱신 완료: {len(cache_tasks)}건 (TTL: {TTL_ANALYSIS}s)") # =============================================== # ─────────────────────────────────────────────────────── break # 성공 시 재시도 루프 탈출 except Exception as e: wait = 10 * (2 ** attempt) # 10s → 20s → 40s if attempt < max_retries - 1: logger.warning( f"⚠️ [Batch] 분석 실패 ({attempt + 1}/{max_retries}), " f"{wait}초 후 재시도: {e}" ) await asyncio.sleep(wait) else: logger.error(f"❌ Batch analysis error (최대 재시도 초과): {e}", exc_info=True) await asyncio.sleep(interval) except asyncio.CancelledError: logger.info("⏹️ Batch analysis scheduler stopped") raise async def auto_trade_scheduler(): """1분 간격 자동매매 스케줄러. Redis 캐시(recommendations:latest)를 먼저 확인 → 캐시 히트 시 DB 조회 없이 매매 실행. 케시 미스(30분 TTL 만료) 시만 get_rule_based_recommendations_from_db() 호출. 주문 직전 MCP RISK_ASSESSMENT 호출 → 승인 시만 주문. """ await asyncio.sleep(2) from app.api.endpoints.recommendations import ( get_rule_based_recommendations_from_db, execute_auto_trade_background, ) from app.api.endpoints.portfolio import check_and_execute_exit_trades from app.api.endpoints.mcp import request_mcp_risk_assessment from app.core.redis import get_json logger.info("⏱️ Auto-trade scheduler started (every 60s, Redis→DB fallback mode)") try: while True: try: if not is_market_open(): logger.info("💤 Market is closed. Next check in 30 minutes.") await asyncio.sleep(1800) continue async with AsyncSessionLocal() as session: # 1. 보유 종목 손절/익절 체크 logger.info("💰 Checking portfolio for stop-loss/take-profit...") try: await check_and_execute_exit_trades(session) except Exception as e: logger.error(f"Exit trade check error: {e}", exc_info=True) # 2. 자동매매: Redis 캐시 우선 → cache miss 시만 DB 조회 if settings.AUTO_TRADE_ENABLED: # ── Redis 캐시 확인 (10분 배치 결과, TTL 30분) ──────────────── rec = await get_json("recommendations:latest") if rec: rec_by_rule = rec.get('recommendations_by_rule', []) rec_count = len(rec_by_rule) if isinstance(rec_by_rule, (list, dict)) else 0 logger.info( "⚡ Redis cache hit: recommendations:latest " f"(rule 수: {rec_count}) " "→ DB 조회 생략" ) # 포맷 정규화 (다른 키 조합 대응) rec = _normalize_recommendations(rec) else: logger.info("🔄 Redis cache miss → DB에서 추천 조회") rec = await get_rule_based_recommendations_from_db(session) # ────────────────────────────────────────────────────── if rec and rec.get("recommendations_by_rule"): # ── MCP RISK_ASSESSMENT (공통 함수, 이슈 4) ────────── recommendations = rec.get("recommendations_by_rule", []) # Handle both list and dict formats for backwards compatibility if isinstance(recommendations, dict): # Old format: {rule_name: [stocks]} recommendation_count = sum(len(v) for v in recommendations.values()) rules_list = list(recommendations.keys()) top_stocks_list = [ 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] else: # New format: [{rule_id, rule_name, stocks: [...]}] recommendation_count = sum( len(rule_group.get("stocks", [])) for rule_group in recommendations if isinstance(rule_group, dict) ) rules_list = [ rule_group.get("rule_name", rule_group.get("rule_id", "")) for rule_group in recommendations if isinstance(rule_group, dict) ] top_stocks_list = [ item.get("stock_code") or item.get("code", "") for rule_group in recommendations if isinstance(rule_group, dict) for item in rule_group.get("stocks", [])[:3] ][:10] trade_plan = { "action": "BUY", "strategy": "rule_based_batch", "recommendation_count": recommendation_count, "rules": rules_list, "top_stocks": top_stocks_list, } portfolio: dict = {} signals = { "source": "db_recommendations", "rules_triggered": rules_list, } approve, reason = await request_mcp_risk_assessment( trade_plan, portfolio, signals ) # ───────────────────────────────────────────────────── if not approve: logger.warning( f"🛑 MCP RISK_ASSESSMENT DENY → 주문 스킵. 사유: {reason}" ) try: from app.services.db_worker import enqueue_db_write await enqueue_db_write({ "type": "log_mcp_deny", "reason": reason, "trade_plan": trade_plan, }) except Exception: pass else: if reason: logger.info( f"✅ MCP RISK_ASSESSMENT APPROVE. 참고: {reason}" ) logger.info("📊 Found recommendations, executing auto-trade") await execute_auto_trade_background(rec, session) else: logger.info("⏭️ Auto-trade skipped: no recommendations") else: logger.info("⏸️ Auto-trade disabled - only monitoring stop-loss/take-profit") except Exception as e: logger.error(f"❌ Auto-trade scheduler error: {e}", exc_info=True) await asyncio.sleep(60) except asyncio.CancelledError: logger.info("⏹️ Auto-trade scheduler stopped") raise async def day_trade_scheduler(): """30초 간격 데이트레이딩 스케줄러. - Opening Range 수집 (09:00~09:30) - 진입 신호 스캔 + 주문 실행 (09:30~15:00) - EOD 강제 청산 (15:20~15:30) - 장 외 시간: 30분 슬립 """ await asyncio.sleep(5) from app.api.endpoints.day_trading import run_day_trading_cycle logger.info("📈 Day-trade scheduler started (every 30s)") try: while True: if not is_market_open(): await asyncio.sleep(1800) continue if not settings.DAY_TRADE_ENABLED: await asyncio.sleep(60) continue try: result = await run_day_trading_cycle() phase = result.get("phase", "") if result.get("entries"): logger.info(f"📈 데이트레이딩 진입: {result['entries']}") if result.get("exits"): logger.info(f"📉 데이트레이딩 청산: {result['exits']}") if phase not in ("market_closed", "outside_window"): logger.debug(f"Day-trade cycle [{phase}]: {result.get('time')}") except Exception as e: logger.error(f"❌ Day-trade scheduler error: {e}", exc_info=True) await asyncio.sleep(30) except asyncio.CancelledError: logger.info("⏹️ Day-trade scheduler stopped") raise async def krx_data_collector_scheduler(): """장 종료 후 KRX 일봉 데이터 자동 수집 스케줄러. 매일 15:40 KST 이후 collect_all(update_mode=True) 를 1회 실행. 이미 오늘 수집 완료된 경우 다음 날까지 스킵. """ import pytz as _pytz from datetime import datetime as _dt, date as _date _KST = _pytz.timezone("Asia/Seoul") _last_collected: _date | None = None await asyncio.sleep(30) # 부팅 안정화 logger.info("📅 KRX data collector scheduler started (daily after 15:40 KST)") try: while True: now_kst = _dt.now(_KST) today = now_kst.date() # 오늘 이미 수집했으면 자정까지 대기 if _last_collected == today: # 다음 날 16:00까지 남은 초 계산 from datetime import timedelta as _td tomorrow_16 = _KST.localize( _dt(today.year, today.month, today.day, 16, 0, 0) ) + _td(days=1) sleep_sec = (tomorrow_16 - now_kst).total_seconds() logger.info(f"📅 KRX 오늘 수집 완료. 다음 수집까지 {sleep_sec/3600:.1f}시간 대기") await asyncio.sleep(max(sleep_sec, 60)) continue # 15:40 이전이면 대기 collect_hour, collect_min = 15, 40 if (now_kst.hour, now_kst.minute) < (collect_hour, collect_min): # 오늘 15:40까지 남은 초 target = _KST.localize( _dt(today.year, today.month, today.day, collect_hour, collect_min, 0) ) sleep_sec = (target - now_kst).total_seconds() if sleep_sec > 0: logger.info(f"📅 KRX 수집 대기 중... 15:40까지 {sleep_sec/60:.0f}분 남음") await asyncio.sleep(min(sleep_sec, 1800)) # 최대 30분마다 재확인 continue # 주말(토/일)은 스킵하고 다음 날 재확인 if today.weekday() >= 5: await asyncio.sleep(3600) continue # 수집 실행 (최대 3회 재시도, 30분 간격) max_krx_retries = 3 for krx_attempt in range(max_krx_retries): try: suffix = f" (재시도 {krx_attempt + 1}/{max_krx_retries})" if krx_attempt > 0 else "" logger.info(f"📥 [KRX] 일봉 데이터 증분 수집 시작 (update mode){suffix}...") from collect_krx_data import collect_all await collect_all(update_mode=True) _last_collected = today logger.info("✅ [KRX] 일봉 데이터 수집 완료") break except Exception as e: if krx_attempt < max_krx_retries - 1: logger.warning( f"⚠️ [KRX] 수집 실패 ({krx_attempt + 1}/{max_krx_retries}), " f"30분 후 재시도: {e}" ) await asyncio.sleep(1800) else: logger.error(f"❌ [KRX] 데이터 수집 최대 재시도 초과: {e}", exc_info=True) except asyncio.CancelledError: logger.info("⏹️ KRX data collector scheduler stopped") raise @asynccontextmanager async def lifespan(app: FastAPI): """애플리케이션 시작/종료 시 실행될 코드""" logger.info("Starting Auto Stock Trading System...") logger.info(f"KIS API Mode: {'모의투자 (VIRTUAL)' if settings.is_virtual_trading else '실전투자 (REAL)'}") logger.info(f"KIS Base URL: {settings.KOREA_INVESTMENT_API_BASE_URL}") logger.info(f"KIS Account: {settings.KOREA_INVESTMENT_ACCOUNT_NUMBER}") # ── 필수 환경변수 검증 (조기 경보) ────────────────────────────────────────── for warn_msg in settings.validate_required(): logger.warning(f"⚠️ [Config] {warn_msg}") # DB 테이블 자동 생성 # 컬럼 추가/테이블 생성 DDL 은 Alembic migration 으로 관리됨. # 배포 전 `cd backend && alembic upgrade head` 를 실행할 것. # (alembic/versions/0001_initial_schema_columns.py) try: logger.info(f"Connecting to database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'unknown'}") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) logger.info("✅ Database tables created successfully") try: from app.services.loss_tracker import loss_tracker async with AsyncSessionLocal() as session: await loss_tracker.load_from_db(session) except Exception as e: logger.warning(f"⚠️ loss_tracker 초기화 실패 (non-critical): {e}") # ── 전략 파라미터 DB 초기화 (config.py 기본값 → strategy_configs 테이블 seed) ── try: from app.services.strategy_config_service import strategy_cfg await strategy_cfg.auto_init() except Exception as e: logger.warning(f"⚠️ strategy_cfg 초기화 실패 (non-critical): {e}") # ──────────────────────────────────────────────────────────────────────────── try: from app.models.models import StockAnalysis, TradeHistory, Portfolio from sqlalchemy import delete from datetime import datetime, timedelta async with AsyncSessionLocal() as session: _kst = pytz.timezone("Asia/Seoul") _today_kst = datetime.now(_kst).date() cutoff_date = _today_kst - timedelta(days=7) result = await session.execute( delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date) ) if result.rowcount > 0: logger.info(f"🗑️ Deleted {result.rowcount} old stock_analysis records") cutoff_datetime = _today_kst - timedelta(days=1) result = await session.execute( delete(TradeHistory).where( TradeHistory.status.in_(['PENDING', 'FAILED']), TradeHistory.created_at < cutoff_datetime, ) ) if result.rowcount > 0: logger.info(f"🗑️ Deleted {result.rowcount} old PENDING/FAILED trades") result = await session.execute( delete(Portfolio).where(Portfolio.quantity <= 0) ) if result.rowcount > 0: logger.info(f"🗑️ Deleted {result.rowcount} empty portfolio records") await session.commit() logger.info("✅ Test data cleanup completed") except Exception as e: logger.warning(f"⚠️ Test data cleanup failed (non-critical): {e}") except ConnectionRefusedError as e: logger.error(f"❌ Database connection refused: {e}") raise except Exception as e: logger.error(f"❌ Failed to create database tables: {e}") import traceback logger.error(traceback.format_exc()) raise try: from app.api.endpoints.day_trading import restore_positions_from_db await restore_positions_from_db() except Exception as e: logger.warning(f"⚠️ 데이트레이딩 포지션 복구 실패 (non-critical): {e}") # ── httpx 싱글톤 클라이언트 초기화 ────────────────────────────────────── from app.api.endpoints.mcp import init_http_client, close_http_client await init_http_client() # ── Redis 클라이언트 초기화 ─────────────────────────────────────────────── from app.core.redis import init_redis, close_redis await init_redis() # 워커 + 스케줄러 시작 app.state.db_worker_task = asyncio.create_task(db_writer_worker()) app.state.auto_trade_task = asyncio.create_task(auto_trade_scheduler()) app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler()) app.state.day_trade_task = asyncio.create_task(day_trade_scheduler()) app.state.krx_collector_task = asyncio.create_task(krx_data_collector_scheduler()) # KIS 실시간 스트림 시작 (설정으로 제어 가능) if settings.KIS_WS_ENABLED: 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 실시간 스트림 태스크 시작 (KIS_WS_ENABLED=True)") else: logger.info("⏭️ KIS 실시간 스트림 비활성화됨 (KIS_WS_ENABLED=False in .env)") app.state.kis_stream_task = None yield # 태스크 정리 for task_attr in ( "auto_trade_task", "batch_analysis_task", "day_trade_task", "krx_collector_task", "db_worker_task", "kis_stream_task", ): try: task = getattr(app.state, task_attr, None) if task: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task except Exception as e: logger.warning(f"Failed to stop {task_attr} gracefully: {e}") # ── httpx 클라이언트 종료 ───────────────────────────────────────────────── try: await close_http_client() except Exception as e: logger.warning(f"Failed to close http_client: {e}") # ── Redis 클라이언트 종료 ───────────────────────────────────────────────── try: await close_redis() except Exception as e: logger.warning(f"Failed to close Redis: {e}") logger.info("Shutting down Auto Stock Trading System...") await engine.dispose() app = FastAPI( title=settings.APP_NAME, description="Claude AI와 MCP를 활용한 자동 주식 매매 시스템", version="1.0.0", lifespan=lifespan, debug=settings.DEBUG, ) # CORS 설정 _cors_origins = getattr(settings, "CORS_ORIGINS", None) if _cors_origins: _allowed_origins = [o.strip() for o in _cors_origins.split(",") if o.strip()] else: _allowed_origins = [ "http://localhost:5173", "http://localhost:3000", "http://127.0.0.1:5173", "http://127.0.0.1:3000", ] app.add_middleware( CORSMiddleware, allow_origins=_allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["*"], ) logger.info("CORS middleware configured") _LOG_SKIP_PREFIXES = ("/health", "/ws/") @app.middleware("http") async def log_requests(request: Request, call_next): path = request.url.path # /health, /ws/ 는 폴링이 잦아 로그 노이즈가 크므로 debug 레벨로 낮춤 if any(path.startswith(p) for p in _LOG_SKIP_PREFIXES): return await call_next(request) start_time = time.time() logger.info(f"🔵 Request: {request.method} {request.url}") response = await call_next(request) process_time = time.time() - start_time logger.info( f"🟢 Response: {request.method} {request.url} " f"- Status: {response.status_code} - Time: {process_time:.3f}s" ) return response app.include_router(api_router, prefix="/api/v1") # ── 전역 예외 핸들러 ──────────────────────────────────────────────────────────── from fastapi import Request as _Request from fastapi.responses import JSONResponse as _JSONResponse @app.exception_handler(Exception) async def global_exception_handler(_req: _Request, exc: Exception) -> _JSONResponse: """처리되지 않은 예외를 500 JSON으로 변환해 일관된 에러 응답을 보장한다.""" logger.error(f"Unhandled exception: {_req.method} {_req.url} — {exc}", exc_info=True) return _JSONResponse( status_code=500, content={"detail": "내부 서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}, ) @app.get("/") async def root(): return {"message": "Auto Stock Trading System API", "version": "1.0.0", "status": "running"} @app.get("/health") async def health_check(): """DB / Redis 연결 상태를 포함한 헬스체크.""" from sqlalchemy import text as _sa_text from app.core.redis import get_redis db_ok = False redis_ok = False try: async with engine.connect() as conn: await conn.execute(_sa_text("SELECT 1")) db_ok = True except Exception: pass try: r = get_redis() if r: await r.ping() redis_ok = True except Exception: pass status = "healthy" if (db_ok and redis_ok) else "degraded" return { "status": status, "db": "ok" if db_ok else "error", "redis": "ok" if redis_ok else "error", } @app.websocket("/ws/{client_id}") async def websocket_endpoint(websocket: WebSocket, client_id: str): """WebSocket 연결 엔드포인트. 클라이언트→서버 지원 프로토콜: {"type": "ping"} → {"type": "pong"} {"type": "subscribe", "stock_codes": [...]} → ACK {"type": "unsubscribe", "stock_codes": [...]} → ACK {"type": "set_subscriptions", "stock_codes": [...]} → ACK {"type": "get_chart", "stock_code": "...", "period": "D", "limit": 200} → {"type": "chart_data", ...} 그 외 메시지 → echo """ await websocket_manager.connect(websocket, client_id) try: while True: try: data = await asyncio.wait_for(websocket.receive_text(), timeout=60.0) except asyncio.TimeoutError: # 60초 동안 메시지 없음 → ping 전송으로 연결 유지 확인 try: await websocket_manager.send_personal_message( json.dumps({"type": "ping"}), client_id ) except Exception: break continue try: msg = json.loads(data) except (json.JSONDecodeError, AttributeError): await websocket_manager.send_personal_message(f"Echo: {data}", client_id) continue msg_type = msg.get("type", "") if msg_type == "ping": await websocket_manager.send_personal_message( json.dumps({"type": "pong"}), client_id ) continue 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 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 if msg_type == "set_subscriptions": codes = [str(c) for c in msg.get("stock_codes", [])] websocket_manager.set_subscriptions(client_id, codes) # 구독 목록 변경 → KIS 스트림에 즉시 동기화 요청 from app.services.kis_realtime_stream import kis_stream kis_stream.notify_subscription_change() 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, ) # 구독 종목 차트 캐시 사전 워밍업 (백그라운드, 논블로킹) if codes: async def _warmup_chart_cache(stock_codes: list): from app.api.endpoints.market_data import get_chart_data_cached, chart_cache from datetime import datetime for code in stock_codes: cached = chart_cache.get(f"{code}__D") if cached and (datetime.now() - cached["timestamp"]).total_seconds() < 600: continue try: await get_chart_data_cached(code, "D", 100) logger.debug(f"[warmup] {code} 차트 캐시 완료") except Exception as e: logger.debug(f"[warmup] {code} 캐시 실패: {e}") asyncio.create_task(_warmup_chart_cache(codes)) continue if msg_type == "get_chart": stock_code = str(msg.get("stock_code", "")).strip() period = msg.get("period", "D") limit = int(msg.get("limit", 200)) if not stock_code: continue try: from app.api.endpoints.market_data import get_chart_data_cached chart_data = await get_chart_data_cached(stock_code, period, limit) await websocket_manager.send_personal_message( _dumps({"type": "chart_data", "stock_code": stock_code, "period": period, "data": chart_data}), client_id, ) except Exception as e: logger.error(f"get_chart 오류 ({stock_code}): {e}") await websocket_manager.send_personal_message( _dumps({"type": "chart_data", "stock_code": stock_code, "period": period, "data": [], "error": str(e)}), client_id, ) continue 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") @app.post("/api/admin/reset-daily-loss") async def reset_daily_loss(): """테스트용 일일 손실 한도 강제 리셋""" from app.services.mdd_manager import mdd_manager mdd_manager.reset() # 이미 reset() 메서드 존재 [cite:9] return {"status": "ok", "message": "daily loss limit reset"} if __name__ == "__main__": import uvicorn uvicorn.run( "app.main:app", host=settings.BACKEND_HOST, port=settings.BACKEND_PORT, reload=settings.DEBUG, )