- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
1806 lines
88 KiB
Python
1806 lines
88 KiB
Python
"""
|
||
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 warnings
|
||
|
||
warnings.filterwarnings("ignore", message=".*sklearn.utils.parallel.delayed.*")
|
||
import pytz
|
||
|
||
from fastapi import Depends
|
||
from app.core.config import settings
|
||
from app.core.security import require_api_key as _require_api_key
|
||
from app.core.security import require_admin_v2 as _require_admin_v2
|
||
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, is_krx_holiday
|
||
|
||
# 로깅 설정
|
||
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)
|
||
|
||
# [M-6 2026-10-02] WebSocket 인증 토큰(?token=)이 uvicorn 접속 로그에 평문으로 남지 않게 마스킹
|
||
from app.core.logging_config import install_query_token_redaction # noqa: E402
|
||
install_query_token_redaction()
|
||
|
||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# [L-3 2026-10-02] fire-and-forget 태스크 강참조 보관 — 이벤트 루프는 태스크를 약참조로만
|
||
# 들고 있어 참조를 버리면 실행 중에 GC 로 사라질 수 있다(asyncio 문서 경고).
|
||
_background_tasks: set[asyncio.Task] = set()
|
||
|
||
|
||
def _spawn_background(coro) -> asyncio.Task:
|
||
task = asyncio.create_task(coro)
|
||
_background_tasks.add(task)
|
||
task.add_done_callback(_background_tasks.discard)
|
||
return task
|
||
|
||
|
||
# ── 스케줄러 생존/heartbeat 감시 (하드닝 R1) ─────────────────────────────
|
||
# 매매 스케줄러(장중 사이클이 있는 것)만 정체 판정 대상. 배치류는 크래시 알림만.
|
||
_HEARTBEAT_WATCHED = ("swing", "daytrading")
|
||
# config.py 는 동시 수정 중이라 모듈 상수로 둔다(추후 settings 편입 후보)
|
||
_FAILED_TRADE_RETENTION_DAYS = 90
|
||
# stock_analysis 는 (stock_code, analysis_date) UNIQUE 라 종목·일자당 마지막 사이클 1행이다.
|
||
# 7일 보존은 실전 RSI/ml_signal/score 를 1주 뒤 지워 §8.4 live-vs-backtest 대조와 §8.5-21 ML 점수
|
||
# 분포 측정을 막았다. 일 ~800행 x 400일 = 약 32만 행이라 부담이 없고 조회 쿼리는 모두 일자 조건이다.
|
||
_STOCK_ANALYSIS_RETENTION_DAYS = 400
|
||
# db_worker 크래시 자동 재시작 백오프/한도 (_spawn_scheduler_v2 와 동일 정책)
|
||
_DB_WORKER_MAX_RESTARTS_PER_HOUR = 5
|
||
_DB_WORKER_RESTART_BACKOFF_SEC = (1.0, 5.0, 15.0, 30.0, 60.0)
|
||
_DB_WORKER_LIMIT_COOLDOWN_SEC = 600.0
|
||
_db_worker_restarts: list = [] # 재시작 monotonic 시각
|
||
_db_worker_restart_total = 0
|
||
_db_worker_restart_limit_hit = False
|
||
_db_worker_restart_handle = None
|
||
_HB_SCHEDULER_MAX_RESTARTS_PER_HOUR = 5
|
||
_HB_SCHEDULER_RESTART_BACKOFF_SEC = (5.0, 10.0, 30.0, 60.0, 60.0) # 재시작 지수 백오프
|
||
_HB_SCHEDULER_LIMIT_COOLDOWN_SEC = 600.0 # 한도 소진 후 재시도 최소 대기
|
||
_HB_KIS_STREAM_SILENT_SECONDS = 600.0 # 장중 WS 무수신 경보 기준
|
||
_restart_handles: dict = {} # name -> call_later 핸들(중복 예약 방지)
|
||
_HB_SCHEDULER_STALE_SECONDS = 600.0
|
||
_HB_SCHEDULER_WATCHDOG_INTERVAL_SEC = 60.0
|
||
_HB_SCHEDULER_ALERT_REPEAT_SEC = 1800.0
|
||
_scheduler_tasks: dict[str, str] = {} # name -> app.state 속성명
|
||
_scheduler_restarts: dict[str, list] = {} # name -> 재시작 monotonic 시각들
|
||
_shutting_down = False
|
||
_stale_alerted_at: dict[str, float] = {}
|
||
|
||
|
||
def _notify_system_warning_bg(category: str, message: str) -> None:
|
||
try:
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
_spawn_background(telegram_notifier.notify_system_warning(category=category, message=message))
|
||
except Exception as _swallowed:
|
||
logger.warning("[main] 시스템 경고 텔레그램 실패(%s): %s", category, _swallowed)
|
||
|
||
|
||
def _restart_delay_v2(attempt_index: int) -> float:
|
||
"""재시작 백오프(초). attempt_index 는 최근 1시간 내 재시작 횟수(0부터)."""
|
||
seq = _HB_SCHEDULER_RESTART_BACKOFF_SEC
|
||
if not seq:
|
||
return 0.0
|
||
return float(seq[min(attempt_index, len(seq) - 1)])
|
||
|
||
|
||
def _spawn_scheduler_v2(application, attr: str, name: str, factory):
|
||
"""스케줄러 태스크 생성 + 종료/크래시 시 알림·백오프 재시작 콜백.
|
||
|
||
한도(시간당 N회) 소진 시에도 영구 포기하지 않고 쿨다운 후 재시도한다(창이 지나면 복구).
|
||
"""
|
||
_scheduler_tasks[name] = attr
|
||
|
||
def _start_task() -> None:
|
||
new_task = asyncio.create_task(factory())
|
||
new_task.add_done_callback(_on_done)
|
||
setattr(application.state, attr, new_task)
|
||
|
||
def _retry(limit_hit: bool) -> None:
|
||
_restart_handles.pop(name, None)
|
||
if _shutting_down:
|
||
return
|
||
now = time.monotonic()
|
||
hist = [t for t in _scheduler_restarts.get(name, []) if now - t < 3600.0]
|
||
if len(hist) >= _HB_SCHEDULER_MAX_RESTARTS_PER_HOUR:
|
||
# 아직 창 안 — 가장 오래된 기록이 빠질 때까지(최소 쿨다운) 다시 대기
|
||
wait = max(_HB_SCHEDULER_LIMIT_COOLDOWN_SEC, 3600.0 - (now - hist[0]) + 1.0)
|
||
_restart_handles[name] = asyncio.get_running_loop().call_later(wait, _retry, True)
|
||
return
|
||
hist.append(now)
|
||
_scheduler_restarts[name] = hist
|
||
if limit_hit:
|
||
logger.warning(f"[main] 스케줄러 '{name}' 쿨다운 후 재시작")
|
||
_start_task()
|
||
|
||
def _on_done(task: asyncio.Task):
|
||
if task.cancelled() or _shutting_down:
|
||
return
|
||
exc = task.exception()
|
||
reason = f"{type(exc).__name__}: {exc}" if exc else "예기치 않게 정상 종료"
|
||
logger.error(f"🚨 스케줄러 '{name}' 종료: {reason}")
|
||
_notify_system_warning_bg("SCHEDULER", f"스케줄러 '{name}' 중단: {reason}")
|
||
if name in _restart_handles:
|
||
return # 이미 재시작 예약됨
|
||
now = time.monotonic()
|
||
hist = [t for t in _scheduler_restarts.get(name, []) if now - t < 3600.0]
|
||
loop = asyncio.get_running_loop()
|
||
if len(hist) >= _HB_SCHEDULER_MAX_RESTARTS_PER_HOUR:
|
||
wait = max(_HB_SCHEDULER_LIMIT_COOLDOWN_SEC, 3600.0 - (now - hist[0]) + 1.0)
|
||
logger.error(
|
||
f"🚨 스케줄러 '{name}' 재시작 한도 초과 — {wait:.0f}초 후 재시도 예약(수동 확인 권장)"
|
||
)
|
||
_notify_system_warning_bg("SCHEDULER", f"'{name}' 재시작 한도 초과 — {wait:.0f}초 후 재시도, 수동 확인 필요")
|
||
_restart_handles[name] = loop.call_later(wait, _retry, True)
|
||
return
|
||
delay = _restart_delay_v2(len(hist))
|
||
logger.warning(f"[main] 스케줄러 '{name}' {delay:.0f}초 후 재시작 예약")
|
||
_restart_handles[name] = loop.call_later(delay, _retry, False)
|
||
|
||
_start_task()
|
||
return getattr(application.state, attr)
|
||
|
||
|
||
def _scheduler_health_v2(application, now: float | None = None, market_open: bool | None = None) -> dict:
|
||
"""스케줄러 생존·사이클 경과시간. stale 은 장중 + heartbeat 보고 스케줄러만 판정."""
|
||
from app.core import heartbeat as _hb
|
||
n = now if now is not None else time.monotonic()
|
||
if market_open is None:
|
||
try:
|
||
market_open = bool(is_market_open())
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:hb_mkt", "[health] 장 상태 확인 실패: %s", _swallowed)
|
||
market_open = False
|
||
stale_limit = _HB_SCHEDULER_STALE_SECONDS
|
||
out: dict = {}
|
||
for name, attr in _scheduler_tasks.items():
|
||
task = getattr(application.state, attr, None)
|
||
alive = bool(task is not None and not task.done())
|
||
age = _hb.age_seconds(name, n)
|
||
stale = bool(market_open and name in _HEARTBEAT_WATCHED and age is not None and age > stale_limit)
|
||
# 한 번도 beat 못 한 채 죽은 감시대상도 장중엔 정체로 본다(age None 맹점)
|
||
dead_silent = bool(market_open and name in _HEARTBEAT_WATCHED and age is None and not alive)
|
||
out[name] = {
|
||
"alive": alive,
|
||
"last_cycle_age": None if age is None else round(age, 1),
|
||
"stale": bool(stale or dead_silent),
|
||
}
|
||
return out
|
||
|
||
|
||
# ── KIS 실시간 스트림 건강 감시 (하드닝 R1-3) ────────────────────────────
|
||
_kis_stream_last_msg_mono: float | None = None
|
||
_kis_stream_probe_installed = False
|
||
|
||
|
||
def _install_kis_stream_probe_v2(stream=None) -> bool:
|
||
"""kis_stream._enqueue_realtime 을 감싸 마지막 tick/quote 수신 시각을 기록한다.
|
||
|
||
kis_realtime_stream.py 를 수정하지 않고 main 에서 관측만 추가(원 동작·인자 불변).
|
||
"""
|
||
global _kis_stream_probe_installed
|
||
if stream is None:
|
||
from app.services.kis_realtime_stream import kis_stream as stream
|
||
if getattr(stream, "_main_probe_installed", False):
|
||
_kis_stream_probe_installed = True
|
||
return False
|
||
orig = stream._enqueue_realtime
|
||
|
||
def _probe(msg_type, stock_code, parsed):
|
||
global _kis_stream_last_msg_mono
|
||
_kis_stream_last_msg_mono = time.monotonic()
|
||
return orig(msg_type, stock_code, parsed)
|
||
|
||
stream._enqueue_realtime = _probe
|
||
stream._main_probe_installed = True
|
||
_kis_stream_probe_installed = True
|
||
return True
|
||
|
||
|
||
def _on_kis_stream_done_v2(task: asyncio.Task) -> None:
|
||
if task.cancelled() or _shutting_down:
|
||
return
|
||
exc = task.exception()
|
||
reason = f"{type(exc).__name__}: {exc}" if exc else "예기치 않게 정상 종료"
|
||
logger.error(f"🚨 KIS 실시간 스트림 태스크 종료: {reason}")
|
||
_notify_system_warning_bg("KIS_STREAM", f"KIS 실시간 스트림 종료: {reason} (시세 푸시 정지, 매매는 REST 사용)")
|
||
|
||
|
||
def _kis_stream_health_v2(application, now: float | None = None, market_open: bool | None = None) -> dict | None:
|
||
"""스트림 비활성이면 None. 장중 무수신이 임계 초과면 silent=True."""
|
||
task = getattr(application.state, "kis_stream_task", None)
|
||
if task is None:
|
||
return None
|
||
n = now if now is not None else time.monotonic()
|
||
if market_open is None:
|
||
try:
|
||
market_open = bool(is_market_open())
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:ks_mkt", "[health] 장 상태 확인 실패: %s", _swallowed)
|
||
market_open = False
|
||
alive = not task.done()
|
||
last = _kis_stream_last_msg_mono
|
||
age = None if last is None else n - last
|
||
limit = float(_HB_KIS_STREAM_SILENT_SECONDS)
|
||
# 장중 + 태스크 사망, 또는 장중 + (수신 이력 있고 임계 초과)
|
||
silent = bool(market_open and (not alive or (age is not None and age > limit)))
|
||
return {"alive": alive, "last_msg_age": None if age is None else round(age, 1), "silent": silent}
|
||
|
||
|
||
def _db_worker_health_v2(application) -> dict:
|
||
task = getattr(application.state, "db_worker_task", None)
|
||
try:
|
||
from app.services.db_worker import get_db_task_queue
|
||
qsize = get_db_task_queue().qsize()
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:hb_q", "[health] db 큐 크기 확인 실패: %s", _swallowed)
|
||
qsize = -1
|
||
return {
|
||
"alive": bool(task is not None and not task.done()),
|
||
"qsize": qsize,
|
||
"restarts_total": _db_worker_restart_total,
|
||
"restart_limit_hit": _db_worker_restart_limit_hit,
|
||
}
|
||
|
||
|
||
def _db_worker_restart_plan_v2(now: float) -> tuple[float, bool]:
|
||
"""크래시 후 재시작 대기(초)와 한도 소진 여부. 최근 1시간 재시작 이력을 갱신하지 않는다."""
|
||
hist = [t for t in _db_worker_restarts if now - t < 3600.0]
|
||
if len(hist) >= _DB_WORKER_MAX_RESTARTS_PER_HOUR:
|
||
return max(_DB_WORKER_LIMIT_COOLDOWN_SEC, 3600.0 - (now - hist[0]) + 1.0), True
|
||
seq = _DB_WORKER_RESTART_BACKOFF_SEC
|
||
return (float(seq[min(len(hist), len(seq) - 1)]) if seq else 0.0), False
|
||
|
||
|
||
def _on_db_worker_done_v2(application, task: asyncio.Task, factory) -> None:
|
||
"""db_worker 종료 콜백: 백오프 후 재시작, 시간당 한도 초과 시 error+/health 승격 후 쿨다운 재시도."""
|
||
global _db_worker_restart_handle, _db_worker_restart_limit_hit, _db_worker_restart_total
|
||
if task.cancelled() or _shutting_down:
|
||
return
|
||
exc = task.exception()
|
||
if not exc:
|
||
return
|
||
if _db_worker_restart_handle is not None:
|
||
return # 이미 재시작 예약됨
|
||
wait, limit_hit = _db_worker_restart_plan_v2(time.monotonic())
|
||
_db_worker_restart_limit_hit = limit_hit
|
||
if limit_hit:
|
||
logger.error(f"🚨 db_worker 재시작 한도 초과: {type(exc).__name__}: {exc} — {wait:.0f}초 후 재시도(수동 확인 필요)")
|
||
else:
|
||
logger.error(f"db_worker 크래시: {type(exc).__name__}: {exc}, {wait:.1f}초 후 자동 재시작...")
|
||
_notify_system_warning_bg(
|
||
"DB_WORKER",
|
||
f"db_worker 크래시({'한도 초과, ' if limit_hit else ''}{wait:.0f}초 후 재시작): {type(exc).__name__}: {exc}",
|
||
)
|
||
|
||
def _restart() -> None:
|
||
global _db_worker_restart_handle, _db_worker_restart_total
|
||
_db_worker_restart_handle = None
|
||
if _shutting_down:
|
||
return
|
||
_db_worker_restarts[:] = [t for t in _db_worker_restarts if time.monotonic() - t < 3600.0]
|
||
_db_worker_restarts.append(time.monotonic())
|
||
_db_worker_restart_total += 1
|
||
new_task = asyncio.create_task(factory())
|
||
new_task.add_done_callback(lambda t: _on_db_worker_done_v2(application, t, factory))
|
||
application.state.db_worker_task = new_task
|
||
|
||
_db_worker_restart_handle = asyncio.get_running_loop().call_later(wait, _restart)
|
||
|
||
|
||
async def _heartbeat_watchdog_v2(application):
|
||
"""60초마다 장중 정체/사망 스케줄러를 점검해 텔레그램 경보(30분 간격 재경보)."""
|
||
interval = _HB_SCHEDULER_WATCHDOG_INTERVAL_SEC
|
||
realert = _HB_SCHEDULER_ALERT_REPEAT_SEC
|
||
while True:
|
||
await asyncio.sleep(interval)
|
||
try:
|
||
h = _scheduler_health_v2(application)
|
||
now = time.monotonic()
|
||
for name, info in h.items():
|
||
if not info["stale"]:
|
||
_stale_alerted_at.pop(name, None)
|
||
continue
|
||
if now - _stale_alerted_at.get(name, -1e9) < realert:
|
||
continue
|
||
_stale_alerted_at[name] = now
|
||
logger.error(f"🚨 스케줄러 '{name}' 장중 정체: 마지막 사이클 {info['last_cycle_age']}초 전")
|
||
_notify_system_warning_bg(
|
||
"SCHEDULER", f"'{name}' 장중 사이클 정체 {info['last_cycle_age']}초 (alive={info['alive']})"
|
||
)
|
||
ks = _kis_stream_health_v2(application)
|
||
if ks is None or not ks["silent"]:
|
||
_stale_alerted_at.pop("kis_stream", None)
|
||
elif now - _stale_alerted_at.get("kis_stream", -1e9) >= realert:
|
||
_stale_alerted_at["kis_stream"] = now
|
||
logger.error(f"🚨 KIS 스트림 장중 무수신: {ks}")
|
||
_notify_system_warning_bg(
|
||
"KIS_STREAM", f"KIS 실시간 스트림 장중 무수신 {ks['last_msg_age']}초 (alive={ks['alive']})"
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.warning(f"[heartbeat] 감시 루프 오류: {exc}")
|
||
|
||
|
||
async def _reload_bans_after_replay() -> None:
|
||
"""replay_dlq 가 밴 upsert 를 재큐잉했으면 큐 소진을 기다린 뒤 loss_tracker 를 다시 로드."""
|
||
try:
|
||
from app.services.db_worker import get_db_task_queue, last_replay_requeued_types
|
||
if not ({"upsert_stock_ban", "upsert_stock_ban_v2"} & last_replay_requeued_types):
|
||
return
|
||
try:
|
||
await asyncio.wait_for(
|
||
get_db_task_queue().join(), timeout=float(settings.DLQ_REPLAY_SETTLE_SECONDS)
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.warning(
|
||
f"[DLQ] 재큐잉된 밴 커밋 대기 {settings.DLQ_REPLAY_SETTLE_SECONDS}초 초과 — 커밋된 것만 재로드"
|
||
)
|
||
from app.services.loss_tracker import loss_tracker
|
||
async with AsyncSessionLocal() as session: # 기동 시 1회 SELECT (lifespan, 실시간 계층 아님)
|
||
await loss_tracker.load_from_db(session)
|
||
logger.warning("[DLQ] 재처리된 연패 밴 반영 — loss_tracker 재로드 완료")
|
||
except Exception as e:
|
||
logger.error(f"❌ [DLQ] 밴 재로드 실패 — 재처리된 밴이 재시작 전까지 인메모리에 없을 수 있음: {e}")
|
||
|
||
|
||
def check_shutdown_budget() -> list[str]:
|
||
"""[L-3] 종료 예산(uvicorn 정리 + 백그라운드 대기 + drain) 이 컨테이너 종료 유예를 넘는가."""
|
||
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
|
||
|
||
total = (
|
||
int(settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS)
|
||
+ int(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS)
|
||
+ int(DRAIN_DEADLINE_SECONDS)
|
||
)
|
||
if total >= int(settings.CONTAINER_STOP_GRACE_SECONDS):
|
||
return [
|
||
f"종료 예산 {total}초(uvicorn {settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS} + 백그라운드 "
|
||
f"{settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS} + drain {DRAIN_DEADLINE_SECONDS}) ≥ 종료 유예 "
|
||
f"{settings.CONTAINER_STOP_GRACE_SECONDS}초 — SIGKILL 로 drain 중 쓰기 유실 가능. "
|
||
f"docker-compose stop_grace_period 와 CONTAINER_STOP_GRACE_SECONDS 를 함께 올릴 것"
|
||
]
|
||
return []
|
||
|
||
|
||
async def _await_background_tasks(timeout: float) -> None:
|
||
"""종료 시 `_background_tasks` 를 최대 timeout 초 기다리고, 남은 것은 cancel."""
|
||
pending = [t for t in _background_tasks if not t.done()]
|
||
if not pending:
|
||
return
|
||
done, still = await asyncio.wait(pending, timeout=max(0.0, timeout))
|
||
if still:
|
||
logger.warning(f"[shutdown] 백그라운드 태스크 {len(still)}건이 {timeout:.0f}초 내 미완료 — cancel")
|
||
for t in still:
|
||
t.cancel()
|
||
await asyncio.gather(*still, return_exceptions=True)
|
||
|
||
|
||
async def _drain_all_background_tasks_v2(timeout: float) -> None:
|
||
"""[하드닝 R2 2026-10-09] 종료 시 main `_background_tasks` 와 trade_executor 추적 태스크를
|
||
하나의 timeout 예산 안에서 동시에 기다린다. db_worker 정지 **전에** 호출해야 한다."""
|
||
from app.services import trade_executor as _te
|
||
|
||
results = await asyncio.gather(
|
||
_await_background_tasks(timeout),
|
||
_te.drain_tracked_tasks_v2(timeout),
|
||
_drain_order_path_tasks_v2(timeout),
|
||
return_exceptions=True,
|
||
)
|
||
for r in results:
|
||
if isinstance(r, BaseException) and not isinstance(r, asyncio.CancelledError):
|
||
logger.warning(f"[shutdown] 백그라운드 태스크 drain 실패: {r!r}")
|
||
|
||
|
||
# 종료 시 기다려야 하는 주문 경로 내부 코루틴(asyncio.shield 안쪽 — 외부에서 참조 보관 없음)
|
||
_ORDER_PATH_CORO_NAMES = frozenset({
|
||
"_place_and_finalize_exit_guarded_impl", # DT 청산: 주문 전송 ~ 상태 반영
|
||
"_place_and_record_entry_impl", # DT 진입: 주문 전송 ~ 상태 반영
|
||
})
|
||
|
||
|
||
def _collect_order_path_tasks_v2() -> list:
|
||
"""종료 drain 대상 주문 경로 태스크: 스윙 _inflight_steps + DT 체결 보정 + shield 내부 태스크."""
|
||
found: dict[int, asyncio.Task] = {}
|
||
try:
|
||
from app.services.swing import scheduler as _sw
|
||
for t in list(getattr(_sw, "_inflight_steps", {}).values()):
|
||
found[id(t)] = t
|
||
except Exception as e:
|
||
logger.warning(f"[shutdown] 스윙 진행 중 단계 수집 실패: {e}")
|
||
try:
|
||
from app.services.daytrading import fill_reconciler as _fr
|
||
for t in list(getattr(_fr, "_pending_tasks", ())):
|
||
found[id(t)] = t
|
||
except Exception as e:
|
||
logger.warning(f"[shutdown] DT 체결 보정 태스크 수집 실패: {e}")
|
||
try:
|
||
cur = asyncio.current_task()
|
||
for t in asyncio.all_tasks():
|
||
if t is cur:
|
||
continue
|
||
coro = t.get_coro()
|
||
if getattr(coro, "__name__", "") in _ORDER_PATH_CORO_NAMES:
|
||
found[id(t)] = t
|
||
except Exception as e:
|
||
logger.warning(f"[shutdown] shield 내부 태스크 수집 실패: {e}")
|
||
loop = asyncio.get_running_loop()
|
||
return [t for t in found.values() if not t.done() and t.get_loop() is loop]
|
||
|
||
|
||
def _task_label_v2(t) -> str:
|
||
coro = t.get_coro()
|
||
return getattr(coro, "__qualname__", None) or getattr(coro, "__name__", None) or repr(t)
|
||
|
||
|
||
async def _drain_order_path_tasks_v2(timeout: float) -> int:
|
||
"""[하드닝 R3] 주문 경로 태스크를 같은 예산으로 기다리고, 미완료는 cancel + error + 텔레그램.
|
||
취소한 개수를 반환. KIS 클라이언트·엔진 종료 **전에** 호출해야 한다."""
|
||
pending = _collect_order_path_tasks_v2()
|
||
if not pending:
|
||
return 0
|
||
_done, still = await asyncio.wait(pending, timeout=max(0.0, float(timeout)))
|
||
if not still:
|
||
return 0
|
||
labels = sorted(_task_label_v2(t) for t in still)
|
||
logger.error(
|
||
"[shutdown] 주문 경로 태스크 %d건이 %.0f초 내 미완료 — cancel (주문 결과 불명·장부 미반영 가능, "
|
||
"KIS 주문내역/잔고 수동 확인 필요): %s", len(still), float(timeout), labels,
|
||
)
|
||
_notify_system_warning_bg(
|
||
"SHUTDOWN_ORDER_TASKS",
|
||
f"종료 중 주문 경로 태스크 {len(still)}건 미완료 취소 — 주문내역·잔고 수동 확인: {', '.join(labels)[:300]}",
|
||
)
|
||
for t in still:
|
||
t.cancel()
|
||
await asyncio.gather(*still, return_exceptions=True)
|
||
await _await_background_tasks(3.0) # 방금 띄운 텔레그램 알림이 전송될 시간
|
||
return len(still)
|
||
|
||
|
||
# Import scheduler functions from dedicated module
|
||
from app.services.schedulers import (
|
||
batch_analysis_scheduler,
|
||
post_market_analyzer_scheduler,
|
||
journal_intraday_scheduler,
|
||
krx_data_collector_scheduler,
|
||
news_collect_scheduler,
|
||
pre_market_us_scheduler,
|
||
)
|
||
|
||
|
||
# ── [하드닝 R6] alembic 버전 대조 (기동 가드 H1 보강) ─────────────────────────
|
||
# H1 가드는 portfolio.user_id 하나만 본다. 0012(데이터 마이그레이션)·이후 리비전 누락은
|
||
# 무경보 기동이 되므로 DB alembic_version 과 코드 head 를 대조해 /health 에 노출한다.
|
||
_migration_state: dict = {"checked": False, "db": None, "head": None, "mismatch": False}
|
||
|
||
|
||
def _code_alembic_heads_v2() -> list:
|
||
"""코드(레포) 의 alembic head 리비전 목록. 읽을 수 없으면 예외."""
|
||
from alembic.config import Config
|
||
from alembic.script import ScriptDirectory
|
||
cfg = Config()
|
||
cfg.set_main_option(
|
||
"script_location",
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "alembic"),
|
||
)
|
||
return sorted(ScriptDirectory.from_config(cfg).get_heads())
|
||
|
||
|
||
def _compare_migration_v2(db_versions, heads) -> bool:
|
||
"""True = 불일치. 둘 다 집합으로 비교(다중 head 도 허용)."""
|
||
return set(db_versions or []) != set(heads or [])
|
||
|
||
|
||
async def _check_migration_head_v2(conn) -> dict:
|
||
"""DB alembic_version(읽기 전용 SELECT) 과 코드 head 를 대조하고 상태를 갱신한다."""
|
||
from sqlalchemy import text as _t
|
||
heads = _code_alembic_heads_v2()
|
||
rows = (await conn.execute(_t("SELECT version_num FROM alembic_version"))).fetchall()
|
||
db_versions = sorted(str(r[0]) for r in rows)
|
||
mismatch = _compare_migration_v2(db_versions, heads)
|
||
_migration_state.update(
|
||
checked=True, db=",".join(db_versions) or None, head=",".join(heads) or None, mismatch=mismatch,
|
||
)
|
||
return dict(_migration_state)
|
||
|
||
|
||
_MIGRATION_RECHECK_INTERVAL_SEC = 300.0
|
||
|
||
|
||
def _install_migration_entry_guard_v2(mgr) -> None:
|
||
"""마이그레이션 불일치 동안 신규 진입을 차단한다(SECURITY_FAIL_CLOSED 와 무관).
|
||
|
||
모든 매수 경로가 `mdd_manager.can_open_new_position()` 을 await 하므로 인스턴스 속성으로
|
||
감싼다(mdd_manager.py 시그니처 불변). 청산 경로는 이 메서드를 호출하지 않아 영향 없음.
|
||
_migration_state 는 모듈 전역을 매 호출 조회하므로 재대조로 불일치가 풀리면 자동 해제된다.
|
||
"""
|
||
if getattr(mgr, "_migration_guard_installed", False):
|
||
return
|
||
_orig = mgr.can_open_new_position
|
||
|
||
async def _guarded() -> bool:
|
||
if _migration_state.get("mismatch"):
|
||
return False
|
||
return await _orig()
|
||
|
||
mgr.can_open_new_position = _guarded
|
||
mgr._migration_guard_installed = True
|
||
|
||
|
||
async def _migration_recheck_loop_v2(interval_sec: float = None, iterations: int = None) -> None:
|
||
"""불일치가 남아 있는 동안 주기적으로 재대조하고 critical 로그+텔레그램을 반복한다.
|
||
`alembic upgrade head` 후엔 자동으로 정상 복귀(진입 차단 해제)."""
|
||
interval = _MIGRATION_RECHECK_INTERVAL_SEC if interval_sec is None else interval_sec
|
||
n = 0
|
||
while iterations is None or n < iterations:
|
||
n += 1
|
||
await asyncio.sleep(interval)
|
||
was_mismatch = bool(_migration_state.get("mismatch"))
|
||
try:
|
||
async with engine.connect() as _c:
|
||
st = await _check_migration_head_v2(_c)
|
||
except Exception as exc:
|
||
logger.warning(f"⚠️ [Migration] 재대조 실패(불일치 상태는 유지): {exc}")
|
||
continue
|
||
if st["mismatch"]:
|
||
msg = (f"DB alembic_version={st['db']} ≠ 코드 head={st['head']} — 신규 진입 차단 중. "
|
||
"`alembic upgrade head` 실행 필요.")
|
||
logger.critical(f"❌ [Migration] {msg}")
|
||
_notify_system_warning_bg("migration_mismatch", msg)
|
||
elif was_mismatch:
|
||
logger.warning("✅ [Migration] alembic 버전 일치 복구 — 신규 진입 차단 해제 "
|
||
"(strategy_cfg 캐시는 최대 30초 내 갱신)")
|
||
|
||
|
||
@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}")
|
||
_acct = settings.KOREA_INVESTMENT_ACCOUNT_NUMBER
|
||
_masked_acct = f"{_acct[:4]}****{_acct[-2:]}" if len(_acct) >= 6 else "***"
|
||
logger.info(f"KIS Account: {_masked_acct}")
|
||
|
||
# ── 필수 환경변수 검증 (조기 경보) ──────────────────────────────────────────
|
||
for warn_msg in settings.validate_required():
|
||
logger.warning(f"⚠️ [Config] {warn_msg}")
|
||
|
||
# ── [M-5 2026-10-02] 키 강도 점검 — 기본은 error 로그 + /health "security":"warn" ──
|
||
# SECURITY_FAIL_CLOSED=True 이고 DEBUG=False 면 기동을 중단한다(운영 .env 확인 후 켤 것).
|
||
from app.core.security import security_key_issues
|
||
_key_issues = security_key_issues()
|
||
for _issue in _key_issues:
|
||
logger.error(f"❌ [Security] {_issue}")
|
||
for _budget_issue in check_shutdown_budget():
|
||
logger.error(f"❌ [Shutdown] {_budget_issue}")
|
||
if _key_issues and settings.SECURITY_FAIL_CLOSED and not settings.DEBUG:
|
||
raise RuntimeError(
|
||
"SECURITY_FAIL_CLOSED=True: 약한/기본 키로 기동할 수 없습니다 — " + "; ".join(_key_issues)
|
||
)
|
||
|
||
# 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")
|
||
|
||
# ── H1: alembic 마이그레이션 누락 조기 감지 ──────────────────────────
|
||
# `create_all()`은 신규 테이블만 만들고 기존 테이블에 컬럼을 추가하지 않는다.
|
||
# 멀티테넌트 1단계는 portfolio 등 기존 테이블에 `user_id` 컬럼을 추가했으므로,
|
||
# 배포 시 `alembic upgrade head`를 건너뛰면 이 컬럼이 없는 채로 기동돼
|
||
# SQLAlchemy가 매핑된 모든 컬럼을 SELECT/INSERT하다 UndefinedColumnError로
|
||
# 실패한다(자동매매 기록 전체 유실). 여기서 명시적으로 기동을 중단시킨다.
|
||
from sqlalchemy import text as _sa_text_check
|
||
async with engine.connect() as _check_conn:
|
||
_col_exists = (await _check_conn.execute(_sa_text_check(
|
||
"SELECT 1 FROM information_schema.columns "
|
||
"WHERE table_name='portfolio' AND column_name='user_id'"
|
||
))).fetchone()
|
||
if _col_exists is None:
|
||
logger.critical(
|
||
"❌ [H1] portfolio.user_id 컬럼이 없습니다 — alembic 마이그레이션이 적용되지 않았습니다. "
|
||
"배포 절차 위반: `cd backend && alembic upgrade head` 를 먼저 실행한 뒤 다시 기동하세요."
|
||
)
|
||
raise RuntimeError(
|
||
"Migration required: portfolio.user_id column missing. Run `alembic upgrade head` before starting the app."
|
||
)
|
||
|
||
# [하드닝 R6] alembic_version ↔ 코드 head 대조. 대조 불가(alembic 디렉터리/테이블 부재)는
|
||
# 불일치로 단정하지 않고 warning 만 남긴다. 불일치는 critical+텔레그램+/health degraded,
|
||
# SECURITY_FAIL_CLOSED=True(DEBUG=False) 면 기동 중단.
|
||
_mig = None
|
||
try:
|
||
async with engine.connect() as _mig_conn:
|
||
_mig = await _check_migration_head_v2(_mig_conn)
|
||
except Exception as _mig_exc:
|
||
logger.warning(f"⚠️ [Migration] alembic 버전 대조 불가(추정: alembic 디렉터리/테이블 없음): {_mig_exc}")
|
||
if _mig is not None and _mig["mismatch"]:
|
||
_mig_msg = (
|
||
f"DB alembic_version={_mig['db']} ≠ 코드 head={_mig['head']} — "
|
||
"`alembic upgrade head` 를 먼저 실행하세요(전략 파라미터 캐시가 낡은 값으로 채워질 수 있음)."
|
||
)
|
||
logger.critical(f"❌ [Migration] {_mig_msg}")
|
||
_notify_system_warning_bg("migration_mismatch", _mig_msg)
|
||
if settings.SECURITY_FAIL_CLOSED and not settings.DEBUG:
|
||
raise RuntimeError("SECURITY_FAIL_CLOSED=True: 마이그레이션 불일치 — " + _mig_msg)
|
||
# 기동은 허용하되 신규 진입은 차단 + 주기 재경고(R8). 가드 설치는 try 밖 단순 대입.
|
||
from app.services.mdd_manager import mdd_manager as _mdd_for_guard
|
||
_install_migration_entry_guard_v2(_mdd_for_guard)
|
||
app.state.migration_recheck_task = asyncio.create_task(_migration_recheck_loop_v2())
|
||
|
||
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, select, func
|
||
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=_STOCK_ANALYSIS_RETENTION_DAYS)
|
||
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")
|
||
|
||
# [M-1 2026-10-02] PENDING 은 삭제하지 않는다. KIS 주문접수 후 체결 확인이
|
||
# 실패한 BUY 는 영구 PENDING 으로 남는데, 그 행이 유입잔고 강등(swing→external)
|
||
# 방어의 근거다(portfolio.py `_NON_FILL_BUY_STATUSES` 주석). 지우면 재기동마다
|
||
# 정상 보유 포지션이 강등 후보가 된다. 당일 판정 쿼리는 모두 created_at>=오늘
|
||
# 조건이라 오래된 PENDING 이 남아도 매매 판정에 영향이 없다.
|
||
# FAILED(주문 거부, 주식 미보유)만 1일 경과분을 정리한다 — 기동 시 1회의
|
||
# 부트스트랩 정리이며 실시간 계층이 아니다.
|
||
# [하드닝 R2] 보존 90일 + 삭제 전 일자·사유별 건수 로그 — 1일 보존은 '왜 매수 0건'
|
||
# 조사 근거(거부 빈도·사유)를 재기동마다 지웠다.
|
||
cutoff_datetime = _today_kst - timedelta(days=_FAILED_TRADE_RETENTION_DAYS)
|
||
try:
|
||
_summ = await session.execute(
|
||
select(func.date(TradeHistory.created_at), TradeHistory.message, func.count())
|
||
.where(TradeHistory.status == 'FAILED', TradeHistory.created_at < cutoff_datetime)
|
||
.group_by(func.date(TradeHistory.created_at), TradeHistory.message)
|
||
)
|
||
for _d, _m, _c in _summ.all():
|
||
logger.info(f"[cleanup] FAILED 삭제 예정 {_d} x{_c}: {str(_m)[:80]}")
|
||
except Exception as _se:
|
||
logger.warning(f"[cleanup] FAILED 요약 조회 실패: {type(_se).__name__}: {_se}")
|
||
result = await session.execute(
|
||
delete(TradeHistory).where(
|
||
TradeHistory.status == 'FAILED',
|
||
TradeHistory.created_at < cutoff_datetime,
|
||
)
|
||
)
|
||
if result.rowcount > 0:
|
||
logger.info(f"🗑️ Deleted {result.rowcount} old 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
|
||
|
||
# ── Alembic 자동 마이그레이션: 의도적으로 비활성화 ────────────────────────
|
||
# [FIX] alembic_command.upgrade()를 lifespan에서 호출하면 두 가지 문제가 있었다:
|
||
# (1) env.py가 이벤트 루프 내부에서 asyncio.run()을 호출 → 항상 실패
|
||
# ("asyncio.run() cannot be called from a running event loop"). 즉 수개월간
|
||
# 실제 마이그레이션이 한 번도 동작하지 않았다.
|
||
# (2) 그 실패 이전에 env.py 모듈 로딩 시 fileConfig()가 alembic.ini의 root 로거
|
||
# 설정(level=WARN)으로 앱 로깅을 덮어써, startup 이후 모든 INFO 로그(스케줄러
|
||
# 사이클/진입 로그 등)가 침묵 → 매매 관측 불가의 원인.
|
||
# 스키마는 위의 create_all()로 생성되며, 컬럼 변경 등 실제 마이그레이션은 배포 시
|
||
# CLI(`alembic upgrade head`)로 수행한다. 따라서 여기서는 호출하지 않는다.
|
||
logger.info("ℹ️ Alembic auto-migration skipped (schema via create_all; migrations run via CLI)")
|
||
|
||
# ── 멀티테넌트 부트스트랩: 관리자 계정 시드 + legacy KIS 자격증명 이관 + user_id 백필 ──
|
||
try:
|
||
from app.services.tenant.bootstrap import ensure_bootstrap_admin
|
||
await ensure_bootstrap_admin()
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ 멀티테넌트 부트스트랩 실패 (non-critical): {e}")
|
||
|
||
# ── KRX 일봉 데이터 초기 수집 (DB 비어있을 때만 백그라운드 실행) ──────
|
||
try:
|
||
from app.models.models import StockDailyPrice
|
||
from sqlalchemy import select as _select, func as _func
|
||
async with AsyncSessionLocal() as _check_session:
|
||
_count = await _check_session.execute(
|
||
_select(_func.count()).select_from(StockDailyPrice)
|
||
)
|
||
_total = _count.scalar() or 0
|
||
if _total == 0 and settings.DATAGOKR_API_KEY:
|
||
async def _initial_krx_collect():
|
||
await asyncio.sleep(10) # 부팅 안정화 대기
|
||
try:
|
||
from collect_krx_data import collect_all
|
||
logger.info("📥 [KRX] 초기 데이터 수집 시작 (stock_daily_prices 비어있음)...")
|
||
await collect_all(update_mode=True)
|
||
logger.info("✅ [KRX] 초기 데이터 수집 완료")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ [KRX] 초기 데이터 수집 실패 (non-critical): {e}")
|
||
_spawn_background(_initial_krx_collect())
|
||
logger.info("📥 [KRX] stock_daily_prices 비어있음 — 백그라운드 초기 수집 예약됨")
|
||
elif _total == 0:
|
||
logger.warning("⚠️ [KRX] stock_daily_prices 비어있으나 DATAGOKR_API_KEY 미설정 — 네이버 차트 폴백 사용")
|
||
else:
|
||
logger.info(f"📊 [KRX] stock_daily_prices: {_total:,}행 확인")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ [KRX] 초기 데이터 확인 실패 (non-critical): {e}")
|
||
|
||
# [2026-09-23] 레거시 day_trading._positions 복원 제거.
|
||
# services/daytrading/scheduler 가 day_trading_state 로 복원하므로, 둘 다 복원하면
|
||
# 같은 포지션이 두 엔진에 존재해 이중 매도가 발생한다.
|
||
|
||
# ── 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()
|
||
|
||
# ── MDD 상태 복원 (Redis 영속화) ────────────────────────────────────────
|
||
try:
|
||
from app.services.mdd_manager import mdd_manager
|
||
from app.services.mdd_state_backup import (
|
||
mdd_backup_loop_v2, restore_mdd_state_v2,
|
||
)
|
||
_mdd_src = await restore_mdd_state_v2(mdd_manager)
|
||
if _mdd_src == "file":
|
||
logger.warning("⚠️ MDD Redis 상태 없음 — 파일 백업에서 peak/플래그 복원 (peak=%s)", f"{mdd_manager.peak_balance:,.0f}")
|
||
await mdd_manager.save_to_redis()
|
||
elif _mdd_src == "none":
|
||
logger.warning("🚨 MDD 상태 복원 실패(Redis·파일 모두 없음) — peak 가 현재 잔고로 재시드되어 낙폭 이력이 소실될 수 있음")
|
||
try:
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
_spawn_background(telegram_notifier.notify_system_warning(
|
||
category="MDD_STATE",
|
||
message="MDD 상태(Redis/파일) 복원 실패 — peak 재시드, 낙폭 이력 소실 가능. 수동 확인 필요",
|
||
))
|
||
except Exception as _tg:
|
||
logger.warning("[main] MDD 복원 실패 텔레그램 알림 실패: %s", _tg)
|
||
app.state.mdd_backup_task = asyncio.create_task(mdd_backup_loop_v2(mdd_manager))
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ MDD 상태 복원 실패: {e}")
|
||
|
||
# 워커 + 스케줄러 시작 (C5: 자동 재시작 콜백 포함)
|
||
def _restart_db_worker(task: asyncio.Task):
|
||
_on_db_worker_done_v2(app, task, db_writer_worker)
|
||
|
||
app.state.db_worker_task = asyncio.create_task(db_writer_worker())
|
||
app.state.db_worker_task.add_done_callback(_restart_db_worker)
|
||
|
||
# [F4 2026-09-28] DLQ 재처리 — 이전 종료/재시도 실패로 파일에만 남은 DB 쓰기를
|
||
# db_task_queue 에 되돌린다(직접 DB 쓰기 금지, CLAUDE.md §3.2).
|
||
# 워커 태스크 생성 직후에 호출해야 큐 투입분이 곧바로 소비된다.
|
||
try:
|
||
from app.services.db_worker import replay_dlq
|
||
_replayed = await replay_dlq()
|
||
if _replayed:
|
||
logger.warning(f"DLQ 재처리: {_replayed}건 재큐잉")
|
||
except Exception as exc:
|
||
logger.error(f"DLQ 재처리 실패: {exc}", exc_info=True)
|
||
|
||
# [M-3 2026-10-02] loss_tracker.load_from_db 는 위(기동 초반)에서 이미 끝났다. replay 로 되살린
|
||
# 밴은 그 뒤에 커밋되므로 인메모리에 없다 → 커밋을 기다렸다가 다시 읽는다(42일 밴 유실 방지).
|
||
await _reload_bans_after_replay()
|
||
|
||
# [L1 2026-10-02] 사람이 봐야 하는 DLQ(manual/) 가 있으면 텔레그램 시스템 경고 — 기동 로그만으로는 묻힌다
|
||
try:
|
||
from app.services.db_worker import dlq_status_counts
|
||
_dlq_counts = await dlq_status_counts()
|
||
if _dlq_counts.get("manual", 0) > 0:
|
||
logger.error(f"❌ DLQ 수동 확인 대기 {_dlq_counts['manual']}건 (~/.cache/auto-stock/dlq/manual)")
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
_spawn_background(telegram_notifier.notify_system_warning(
|
||
category="DLQ",
|
||
message=(
|
||
f"DB 쓰기 수동 확인 대기 {_dlq_counts['manual']}건 "
|
||
f"(커밋 여부 불명 — 중복 확인 후 처리 필요). 대기 {_dlq_counts['pending']}건, "
|
||
f"격리 {_dlq_counts['expired']}건."
|
||
),
|
||
))
|
||
except Exception as exc:
|
||
logger.warning(f"DLQ 건수 확인/알림 실패: {exc}")
|
||
|
||
# ── 스윙 / 데이트레이딩 분리 스케줄러 (신규 아키텍처) ────────────────────
|
||
from app.services.swing.scheduler import swing_scheduler
|
||
from app.services.daytrading.scheduler import daytrading_scheduler
|
||
# [하드닝 R1] done_callback(크래시 알림·제한적 재시작) + heartbeat 감시
|
||
_spawn_scheduler_v2(app, "swing_trade_task", "swing", swing_scheduler)
|
||
_spawn_scheduler_v2(app, "day_trade_task", "daytrading", daytrading_scheduler)
|
||
|
||
# 배치 분석 / 일봉 수집 / 장 후 분석 (기존 유지)
|
||
_spawn_scheduler_v2(app, "batch_analysis_task", "batch_analysis", batch_analysis_scheduler)
|
||
_spawn_scheduler_v2(app, "krx_collector_task", "krx_collector", krx_data_collector_scheduler)
|
||
_spawn_scheduler_v2(app, "post_market_task", "post_market", post_market_analyzer_scheduler)
|
||
_spawn_scheduler_v2(app, "journal_intraday_task", "journal_intraday", journal_intraday_scheduler)
|
||
app.state.heartbeat_watchdog_task = asyncio.create_task(_heartbeat_watchdog_v2(app))
|
||
|
||
# ── 미국장 자동매매 스케줄러 (US_TRADING_ENABLED=True 시 활성화) ──────────
|
||
if getattr(settings, 'US_TRADING_ENABLED', False):
|
||
from app.services.us_market_scheduler import us_auto_trade_scheduler
|
||
app.state.us_trade_task = asyncio.create_task(us_auto_trade_scheduler())
|
||
logger.info("🇺🇸 US market auto-trade scheduler started (US_TRADING_ENABLED=True)")
|
||
else:
|
||
logger.info("⏭️ US market auto-trade disabled (US_TRADING_ENABLED=False in .env)")
|
||
app.state.us_trade_task = None
|
||
|
||
# ── 뉴스 수집 스케줄러 (NEWS_ENABLED=True 시 활성화) ─────────────────────
|
||
app.state.news_collect_task = asyncio.create_task(news_collect_scheduler())
|
||
logger.info("News collect scheduler started")
|
||
|
||
# ── 미장 프리마켓 심리 분석 (07:00 KST, 1회/일) ────────────────────────
|
||
app.state.pre_market_us_task = asyncio.create_task(pre_market_us_scheduler())
|
||
logger.info("🌍 Pre-market US sentiment scheduler registered (daily 07:00 KST)")
|
||
|
||
# ── ML 재학습 스케줄러 ───────────────────────────────────────────────────
|
||
from app.services.ml_retrain_pipeline import ml_retrain_scheduler
|
||
app.state.ml_retrain_task = asyncio.create_task(ml_retrain_scheduler())
|
||
logger.info("ML retrain scheduler started")
|
||
|
||
# ── AI 전략 스케줄러 (4단계: 장 전/장중/매매직전/장 후) ──────────────────
|
||
async def _ai_strategy_scheduler():
|
||
"""AI 4단계 전략 스케줄러.
|
||
|
||
08:30 장 전 Sonnet 배치 분석 (스윙 50종목)
|
||
08:50 데이트레이딩 종목 자동 선정 (Haiku)
|
||
10분 장중 뉴스 모니터링 (Haiku, 악재 시 Sonnet)
|
||
15:40 장 후 리뷰 + Telegram (Sonnet)
|
||
"""
|
||
import pytz as _tz
|
||
from datetime import datetime as _dt
|
||
|
||
await asyncio.sleep(5)
|
||
_kst = _tz.timezone("Asia/Seoul")
|
||
_pre_market_done_today = None
|
||
_daytrading_done_today = None
|
||
_post_market_done_today = None
|
||
_last_news_check = None
|
||
|
||
logger.info("🤖 [AI Strategy] Scheduler started")
|
||
|
||
while True:
|
||
try:
|
||
now = _dt.now(_kst)
|
||
today = now.date()
|
||
hm = now.hour * 100 + now.minute # HHMM 형식
|
||
|
||
# 평일 + KRX 개장일만 (공휴일·연말 휴장·근로자의날 제외 — L-4)
|
||
if now.weekday() >= 5 or is_krx_holiday(today):
|
||
await asyncio.sleep(300)
|
||
continue
|
||
|
||
# 08:30 — 장 전 스윙 배치 분석
|
||
if 830 <= hm < 845 and _pre_market_done_today != today:
|
||
try:
|
||
from app.services.ai_strategy_service import pre_market_analysis
|
||
result = await pre_market_analysis()
|
||
suitable = [s for s in result.get("suitable_stocks", []) if s.get("suitable")]
|
||
logger.info(f"🤖 [AI Strategy] 장 전 분석 완료: {len(suitable)}종목 적합")
|
||
_pre_market_done_today = today
|
||
except Exception as e:
|
||
logger.warning(f"[AI Strategy] 장 전 분석 실패: {e}")
|
||
|
||
# 08:50 — 데이트레이딩 종목 선정
|
||
if 850 <= hm < 900 and _daytrading_done_today != today:
|
||
if settings.DAY_TRADE_ENABLED:
|
||
try:
|
||
from app.services.ai_strategy_service import select_daytrading_candidates
|
||
candidates = await select_daytrading_candidates()
|
||
logger.info(f"🤖 [AI Strategy] 데이트레이딩 후보: {len(candidates)}종목")
|
||
_daytrading_done_today = today
|
||
except Exception as e:
|
||
logger.warning(f"[AI Strategy] 데이트레이딩 선정 실패: {e}")
|
||
|
||
# 09:10~15:20 — 장중 뉴스 모니터링 (10분 주기)
|
||
if 910 <= hm <= 1520:
|
||
should_check = (
|
||
_last_news_check is None
|
||
or (now - _last_news_check).total_seconds() >= 600
|
||
)
|
||
if should_check:
|
||
try:
|
||
from app.services.ai_strategy_service import monitor_news
|
||
alerts = await monitor_news()
|
||
if alerts:
|
||
logger.warning(f"🚨 [AI Strategy] 뉴스 악재 감지: {len(alerts)}건")
|
||
_last_news_check = now
|
||
except Exception as e:
|
||
warn_throttled(logger, "main:376", f"[AI Strategy] 뉴스 모니터링 실패: {e}")
|
||
|
||
# 15:40 — 장 후 리뷰
|
||
if 1540 <= hm < 1600 and _post_market_done_today != today:
|
||
try:
|
||
from app.services.ai_strategy_service import post_market_review
|
||
review = await post_market_review()
|
||
grade = review.get("overall_grade", "?")
|
||
logger.info(f"🤖 [AI Strategy] 장 후 리뷰 완료: 등급 {grade}")
|
||
_post_market_done_today = today
|
||
except Exception as e:
|
||
logger.warning(f"[AI Strategy] 장 후 리뷰 실패: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Strategy] 스케줄러 오류: {e}")
|
||
|
||
await asyncio.sleep(30)
|
||
|
||
app.state.ai_strategy_task = asyncio.create_task(_ai_strategy_scheduler())
|
||
logger.info("🤖 AI strategy scheduler started (pre-market/news/review)")
|
||
|
||
# ── 적응형 전략 엔진 초기화 + 주기적 갱신 ────────────────────────────────
|
||
async def _adaptive_strategy_scheduler():
|
||
"""적응형 전략 파라미터를 2분 주기로 갱신."""
|
||
from app.services.adaptive_strategy import refresh_adaptive_params
|
||
await asyncio.sleep(10) # 서버 기동 후 10초 대기
|
||
while True:
|
||
try:
|
||
await refresh_adaptive_params()
|
||
except Exception as e:
|
||
logger.warning("[Adaptive] 스케줄러 오류: %s", e)
|
||
await asyncio.sleep(120) # 2분 주기
|
||
app.state.adaptive_strategy_task = asyncio.create_task(_adaptive_strategy_scheduler())
|
||
logger.info("Adaptive strategy engine scheduler started")
|
||
|
||
# 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())
|
||
_install_kis_stream_probe_v2()
|
||
app.state.kis_stream_task.add_done_callback(_on_kis_stream_done_v2)
|
||
logger.info("📡 KIS 실시간 스트림 태스크 시작 (KIS_WS_ENABLED=True)")
|
||
else:
|
||
logger.info("⏭️ KIS 실시간 스트림 비활성화됨 (KIS_WS_ENABLED=False in .env)")
|
||
app.state.kis_stream_task = None
|
||
|
||
yield
|
||
global _shutting_down
|
||
_shutting_down = True
|
||
for _h in list(_restart_handles.values()):
|
||
_h.cancel()
|
||
_restart_handles.clear()
|
||
|
||
# 태스크 정리
|
||
# [H-5 2026-10-02] db_worker 는 **마지막에** 멈춘다. 이전에는 생산자(us_trade·news·
|
||
# adaptive·ai_strategy 등)보다 먼저 cancel 돼, 그 뒤 생산자가 종료 과정에서 넣은 쓰기가
|
||
# drain 되지 못하고 유실됐다. 생산자를 모두 멈춘 뒤 워커가 큐를 비우게 한다.
|
||
for task_attr in (
|
||
"heartbeat_watchdog_task",
|
||
"migration_recheck_task",
|
||
"swing_trade_task",
|
||
"batch_analysis_task",
|
||
"day_trade_task",
|
||
"krx_collector_task",
|
||
"post_market_task",
|
||
"journal_intraday_task",
|
||
"kis_stream_task",
|
||
"us_trade_task",
|
||
"news_collect_task",
|
||
"ml_retrain_task",
|
||
"pre_market_us_task",
|
||
"adaptive_strategy_task",
|
||
"mdd_backup_task",
|
||
"ai_strategy_task",
|
||
"__background__",
|
||
"db_worker_task",
|
||
):
|
||
if task_attr == "__background__":
|
||
# [M-D 2026-10-02] 워커를 멈추기 전에 fire-and-forget 태스크가 enqueue 를 끝낼
|
||
# 시간을 준다(상한 SHUTDOWN_BACKGROUND_DRAIN_SECONDS). 넘기면 cancel —
|
||
# 그 뒤 늦게 들어오는 enqueue 는 db_worker 가 DLQ 로 직접 보관한다.
|
||
# [하드닝 R2 2026-10-09] trade_executor 의 추적 태스크(체결 확인·미체결 지정가
|
||
# 취소·진입가 보정)도 **같은 예산 안에서 동시에** 기다린다(직렬로 붙이면 종료
|
||
# 예산이 SHUTDOWN_BACKGROUND_DRAIN_SECONDS 만큼 늘어 stop_grace_period 40s 를 넘는다).
|
||
# 넘기면 drain_tracked_tasks_v2 가 cancel + "수동 확인 필요" warning 을 남긴다.
|
||
await _drain_all_background_tasks_v2(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS))
|
||
continue
|
||
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}")
|
||
|
||
# ── KIS API httpx 클라이언트 종료 ───────────────────────────────────────
|
||
try:
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
await korea_investment_api.close()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to close KIS http_client: {e}")
|
||
|
||
# ── Redis 클라이언트 종료 ─────────────────────────────────────────────────
|
||
try:
|
||
await close_redis()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to close Redis: {e}")
|
||
|
||
# ── Telegram 클라이언트 종료 ───────────────────────────────────────────────
|
||
try:
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
await telegram_notifier.close()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to close Telegram notifier: {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://localhost:3001",
|
||
"http://127.0.0.1:5173",
|
||
"http://127.0.0.1:3000",
|
||
"http://127.0.0.1:3001",
|
||
]
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=_allowed_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], # PATCH: strategy-config toggle
|
||
allow_headers=["Content-Type", "Authorization", "X-API-Key", "X-Requested-With"],
|
||
expose_headers=["X-Request-Id"],
|
||
)
|
||
logger.info("CORS middleware configured")
|
||
|
||
|
||
# ── [C6] 간이 Rate Limiter (IP 기반, 인메모리) ──────────────────────────────
|
||
_rate_limit_store: dict[str, list[float]] = {} # IP -> [timestamps]
|
||
_RATE_LIMIT_WINDOW = 60 # 초
|
||
_RATE_LIMIT_MAX_REQUESTS = 300 # 윈도우 내 최대 요청 수 (대시보드 폴링 고려)
|
||
_RATE_LIMIT_CLEANUP_INTERVAL = 100 # N 요청마다 stale IP 정리
|
||
_RATE_LIMIT_STALE_THRESHOLD = 3600 # 1시간 이상 미접근 IP 삭제
|
||
_rate_limit_counter = 0 # cleanup 트리거 카운터
|
||
|
||
|
||
@app.middleware("http")
|
||
async def rate_limit_middleware(request: Request, call_next):
|
||
"""IP 기반 간이 rate limiter — 인증 없는 엔드포인트 DoS 방어."""
|
||
global _rate_limit_counter
|
||
# [M-2 2026-10-02] 소켓 주소는 항상 nginx 컨테이너라 전 사용자가 한 버킷을 공유했다.
|
||
# 신뢰 프록시가 덮어쓴 X-Real-IP 기준으로 센다.
|
||
from app.core.security import client_ip_v2
|
||
client_ip = client_ip_v2(request)
|
||
now = time.time()
|
||
|
||
# /health, /ws/, OPTIONS(CORS preflight) 는 rate limit 제외
|
||
path = request.url.path
|
||
if path.startswith(("/health", "/ws/")) or request.method == "OPTIONS":
|
||
return await call_next(request)
|
||
|
||
timestamps = _rate_limit_store.get(client_ip, [])
|
||
# 윈도우 밖 타임스탬프 제거
|
||
timestamps = [t for t in timestamps if now - t < _RATE_LIMIT_WINDOW]
|
||
timestamps.append(now)
|
||
_rate_limit_store[client_ip] = timestamps
|
||
|
||
if len(timestamps) > _RATE_LIMIT_MAX_REQUESTS:
|
||
from fastapi.responses import JSONResponse
|
||
return JSONResponse(
|
||
status_code=429,
|
||
content={"detail": "Too many requests. Please try again later."},
|
||
)
|
||
|
||
# 100 요청마다 stale IP 키 정리 (1시간 이상 미접근)
|
||
_rate_limit_counter += 1
|
||
if _rate_limit_counter >= _RATE_LIMIT_CLEANUP_INTERVAL:
|
||
_rate_limit_counter = 0
|
||
stale_ips = [
|
||
ip for ip, ts_list in _rate_limit_store.items()
|
||
if not ts_list or (now - max(ts_list)) > _RATE_LIMIT_STALE_THRESHOLD
|
||
]
|
||
for ip in stale_ips:
|
||
del _rate_limit_store[ip]
|
||
if stale_ips:
|
||
logger.debug(f"Rate limit store cleanup: {len(stale_ips)} stale IPs removed, {len(_rate_limit_store)} remaining")
|
||
|
||
return await call_next(request)
|
||
|
||
|
||
_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)
|
||
content = {"detail": "내부 서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
|
||
# DEBUG 모드에서만 스택트레이스 노출 (운영 환경 정보 유출 방지)
|
||
if settings.DEBUG:
|
||
content["error"] = str(exc)
|
||
return _JSONResponse(status_code=500, content=content)
|
||
|
||
|
||
@app.get("/")
|
||
async def root():
|
||
return {"message": "Auto Stock Trading System API", "version": "1.0.0", "status": "running"}
|
||
|
||
|
||
_health_cache: dict = {"at": 0.0, "value": None}
|
||
_health_lock = asyncio.Lock()
|
||
|
||
|
||
@app.get("/health")
|
||
async def health_check():
|
||
"""DB / Redis 연결 상태를 포함한 헬스체크.
|
||
|
||
[L-4 2026-10-02] 결과를 HEALTH_CACHE_SECONDS 동안 캐시하고 동시 요청은 한 번만 계산한다 —
|
||
공개 엔드포인트라 폴링·스캔이 몰리면 매 요청 DB 커넥션을 잡아 매매 경로와 풀을 경쟁했다.
|
||
"""
|
||
ttl = float(settings.HEALTH_CACHE_SECONDS)
|
||
cached = _health_cache["value"]
|
||
if cached is not None and (time.monotonic() - _health_cache["at"]) < ttl:
|
||
return cached
|
||
async with _health_lock:
|
||
cached = _health_cache["value"]
|
||
if cached is not None and (time.monotonic() - _health_cache["at"]) < ttl:
|
||
return cached
|
||
value = await _health_check_impl()
|
||
_health_cache["value"] = value
|
||
_health_cache["at"] = time.monotonic()
|
||
return value
|
||
|
||
|
||
_DB_WRITE_STALL_SEC = 300.0 # 큐에 적체가 있는데 마지막 쓰기 성공이 이보다 오래되면 정체
|
||
_BATCH_FAIL_CYCLES_DEGRADED = 2 # 배치 분석 연속 실패 사이클 수 (이상이면 degraded)
|
||
|
||
|
||
def _runtime_health_v2(market_open: bool | None = None) -> tuple[dict, list]:
|
||
"""스윙 청산·MDD fail-safe·적응형·체결보정·뉴스 헬스를 모아 (상세, degraded 사유 코드) 반환.
|
||
|
||
각 getter 실패는 해당 항목만 'unavailable' 로 표시하고 warning 한다(헬스 자체는 죽이지 않는다).
|
||
"""
|
||
detail: dict = {}
|
||
reasons: list = []
|
||
if market_open is None:
|
||
try:
|
||
market_open = bool(is_market_open())
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_mkt", "[health] 장 상태 확인 실패: %s", _swallowed)
|
||
market_open = False
|
||
try:
|
||
from app.services.swing.scheduler import get_swing_scheduler_health_v2, get_mdd_update_health
|
||
sw = get_swing_scheduler_health_v2()
|
||
detail["swing"] = sw
|
||
reasons.extend(str(r) for r in (sw.get("degraded_reasons") or []))
|
||
mdd = get_mdd_update_health()
|
||
detail["mdd"] = mdd
|
||
if mdd.get("blind_block_active"):
|
||
reasons.append("mdd_blind_block")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_swing", "[health] 스윙/MDD 헬스 조회 실패: %s", _swallowed)
|
||
detail["swing"] = "unavailable"
|
||
reasons.append("swing_health_unavailable")
|
||
try:
|
||
ks = _kis_stream_health_v2(app, market_open=market_open)
|
||
if ks is not None:
|
||
detail["kis_stream"] = ks
|
||
if ks["silent"]:
|
||
reasons.append("kis_stream_silent")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_ks", "[health] KIS 스트림 헬스 조회 실패: %s", _swallowed)
|
||
detail["kis_stream"] = "unavailable"
|
||
try:
|
||
from app.services import adaptive_strategy as _adp
|
||
age = _adp.get_last_success_age_sec_v2()
|
||
detail["adaptive"] = {"last_success_age": round(age, 1)}
|
||
if market_open and age > float(_adp._STALE_LIMIT_SEC):
|
||
reasons.append("adaptive_stale")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_adp", "[health] 적응형 헬스 조회 실패: %s", _swallowed)
|
||
detail["adaptive"] = "unavailable"
|
||
try:
|
||
from app.services.swing.fill_sweep import get_fill_sweep_health_v2
|
||
fs = get_fill_sweep_health_v2()
|
||
detail["fill_sweep"] = fs
|
||
if int(fs.get("exhausted") or 0) > 0:
|
||
reasons.append("fill_sweep_exhausted")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_fs", "[health] 체결보정 헬스 조회 실패: %s", _swallowed)
|
||
detail["fill_sweep"] = "unavailable"
|
||
# [R4] 헬스 getter 편입 — db_worker 회로/쓰기 정체, 배치·KRX 수집 정체, 국면 소스 차단
|
||
try:
|
||
from app.services.db_worker import get_worker_health_v2
|
||
wh = get_worker_health_v2()
|
||
detail["db_worker_state"] = wh
|
||
if wh.get("circuit_open"):
|
||
reasons.append("db_circuit_open")
|
||
_lst = wh.get("last_success_ts")
|
||
if (int(wh.get("qsize") or 0) > 0 and _lst is not None
|
||
and time.time() - float(_lst) > _DB_WRITE_STALL_SEC):
|
||
reasons.append("db_write_stalled")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_dbw", "[health] DB 워커 상태 조회 실패: %s", _swallowed)
|
||
detail["db_worker_state"] = "unavailable"
|
||
reasons.append("db_worker_health_unavailable")
|
||
try:
|
||
from app.services.schedulers import get_batch_analysis_health_v2, get_krx_data_health_v2
|
||
bh = get_batch_analysis_health_v2()
|
||
detail["batch_analysis"] = bh
|
||
if int(bh.get("consecutive_failed_cycles") or 0) >= _BATCH_FAIL_CYCLES_DEGRADED:
|
||
reasons.append("batch_analysis_failing")
|
||
kh = get_krx_data_health_v2()
|
||
detail["krx_data"] = kh
|
||
if kh.get("ok") is False:
|
||
reasons.append("krx_data_incomplete")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_batch", "[health] 배치/KRX 헬스 조회 실패: %s", _swallowed)
|
||
detail["batch_analysis"] = "unavailable"
|
||
reasons.append("batch_health_unavailable")
|
||
try:
|
||
from app.services.market_analyzer import get_regime_source_health
|
||
rh = get_regime_source_health()
|
||
detail["regime_source"] = rh
|
||
if market_open and rh.get("blocked"):
|
||
reasons.append("regime_blocked")
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_regime", "[health] 국면 소스 헬스 조회 실패: %s", _swallowed)
|
||
detail["regime_source"] = "unavailable"
|
||
try:
|
||
from app.services.news_sentiment import get_news_collect_health
|
||
detail["news"] = get_news_collect_health()
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:rt_news", "[health] 뉴스 헬스 조회 실패: %s", _swallowed)
|
||
detail["news"] = "unavailable"
|
||
return detail, sorted(set(reasons))
|
||
|
||
|
||
async def _health_check_impl():
|
||
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 as _swallowed:
|
||
warn_throttled(logger, "main:618", "[health] DB 연결 확인 실패: %s", _swallowed)
|
||
|
||
try:
|
||
r = get_redis()
|
||
if r:
|
||
await r.ping()
|
||
redis_ok = True
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:626", "[health] Redis ping 실패: %s", _swallowed)
|
||
|
||
status = "healthy" if (db_ok and redis_ok) else "degraded"
|
||
sched_info = _scheduler_health_v2(app)
|
||
dbw_info = _db_worker_health_v2(app)
|
||
if (not dbw_info["alive"]) or any(v["stale"] or not v["alive"] for v in sched_info.values()):
|
||
status = "degraded"
|
||
# [M-5] 키 설정 경고 — 상세 사유는 공개하지 않는다(기동 로그 error 참조)
|
||
from app.core.security import security_key_issues
|
||
# [L1] DLQ 건수만(내용 비공개). manual = 사람이 확인해야 하는 커밋 여부 불명 쓰기
|
||
try:
|
||
from app.services.db_worker import dlq_status_counts
|
||
_dlq = await dlq_status_counts()
|
||
dlq_info = {"pending": _dlq["pending"], "manual": _dlq["manual"]}
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "main:health_dlq", "[health] DLQ 건수 확인 실패: %s", _swallowed)
|
||
dlq_info = {"pending": -1, "manual": -1}
|
||
_rt_detail, rt_reasons = _runtime_health_v2()
|
||
if dlq_info["manual"] > 0: # 사람이 확인해야 하는 커밋 여부 불명 쓰기
|
||
rt_reasons = sorted(set(rt_reasons) | {"dlq_manual"})
|
||
if _migration_state.get("mismatch"):
|
||
rt_reasons = sorted(set(rt_reasons) | {"migration_mismatch"})
|
||
if rt_reasons:
|
||
status = "degraded"
|
||
return {
|
||
"status": status,
|
||
"db": "ok" if db_ok else "error",
|
||
"redis": "ok" if redis_ok else "error",
|
||
"security": "warn" if security_key_issues() else "ok",
|
||
"dlq": dlq_info,
|
||
"schedulers": sched_info,
|
||
"db_worker": dbw_info,
|
||
# 사유 코드만 공개(수치·오류문구 비공개). 상세는 /health/detail (API 키 필요)
|
||
"degraded_reasons": rt_reasons,
|
||
}
|
||
|
||
|
||
@app.get("/health/detail", dependencies=[Depends(_require_admin_v2)])
|
||
async def health_detail():
|
||
"""스윙 청산·MDD·적응형·체결보정·뉴스 상세 헬스 (관리자 전용, 캐시 없음)."""
|
||
detail, reasons = _runtime_health_v2()
|
||
return {"degraded_reasons": reasons, **detail}
|
||
|
||
|
||
# ── WebSocket 세션 재검증 (하드닝 R3) ─────────────────────────────────────
|
||
_WS_RECHECK_INTERVAL_SEC = 300.0 # is_active 재확인 주기(security 30초 캐시를 경유)
|
||
_ws_owners: dict[str, str] = {} # client_id -> 인증 주체(username)
|
||
|
||
|
||
def _ws_owner_may_connect_v2(client_id: str, owner: str, active_connections) -> bool:
|
||
"""이미 다른 인증 주체가 쓰는 client_id 로는 접속(=기존 연결 교체)할 수 없다."""
|
||
if client_id not in active_connections:
|
||
return True
|
||
prev = _ws_owners.get(client_id)
|
||
return prev is None or prev == owner
|
||
|
||
|
||
def _ws_token_exp_v2(token: str):
|
||
"""세션 JWT 의 exp(epoch 초). API 키 등 exp 가 없으면 None. 서명은 핸드셰이크에서 이미 검증됨."""
|
||
try:
|
||
from jose import jwt as _jwt
|
||
exp = _jwt.get_unverified_claims(token).get("exp")
|
||
return float(exp) if exp is not None else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
async def _ws_session_still_valid_v2(token: str, exp_ts, recheck: bool) -> bool:
|
||
"""exp 초과면 False. recheck 면 resolve_credential_v2(30초 캐시)로 is_active 재확인.
|
||
|
||
조회 실패(503 등)는 연결을 유지한다 — 일시 장애로 정상 사용자를 끊지 않는다(다음 주기에 재시도).
|
||
"""
|
||
if exp_ts is not None and time.time() >= exp_ts:
|
||
return False
|
||
if not recheck:
|
||
return True
|
||
from app.core.security import resolve_credential_v2
|
||
try:
|
||
return (await resolve_credential_v2(token)) is not None
|
||
except Exception as e:
|
||
logger.warning(f"[WS] 세션 재확인 조회 실패 — 연결 유지: {e}")
|
||
return True
|
||
|
||
|
||
# ── WS 하드닝 R5: 재확인 연속 실패 상한 / get_chart 횟수 제한 / 구독 코드 상한 ──
|
||
_WS_RECHECK_MAX_FAILURES = 3 # 재확인 조회가 연속 이만큼 실패하면 연결 종료(핸드셰이크 fail-closed 와 정합)
|
||
_WS_MAX_SUBSCRIBE_CODES = 41 # KIS 실시간 등록 한도 근처 — 그 이상은 잘라낸다
|
||
_WS_CHART_PER_SEC = 2 # get_chart 연결당 초당 상한
|
||
_WS_CHART_PER_MIN = 30 # get_chart 연결당 분당 상한
|
||
|
||
|
||
def _ws_cap_codes_v2(codes) -> list:
|
||
"""구독 코드 목록을 중복 제거·공백 제거 후 _WS_MAX_SUBSCRIBE_CODES 로 자른다."""
|
||
out: list = []
|
||
seen = set()
|
||
for c in codes or []:
|
||
c = str(c).strip()
|
||
if not c or c in seen:
|
||
continue
|
||
seen.add(c)
|
||
out.append(c)
|
||
if len(out) >= _WS_MAX_SUBSCRIBE_CODES:
|
||
break
|
||
return out
|
||
|
||
|
||
class _WsChartLimiterV2:
|
||
"""연결 단위 슬라이딩 윈도우 제한 (초당/분당). allow() 가 False 면 요청을 버린다."""
|
||
|
||
def __init__(self, per_sec: int, per_min: int, clock=time.monotonic):
|
||
self._per_sec = int(per_sec)
|
||
self._per_min = int(per_min)
|
||
self._clock = clock
|
||
self._hits: list = []
|
||
|
||
def allow(self) -> bool:
|
||
now = self._clock()
|
||
self._hits = [t for t in self._hits if now - t < 60.0]
|
||
if len(self._hits) >= self._per_min:
|
||
return False
|
||
if sum(1 for t in self._hits if now - t < 1.0) >= self._per_sec:
|
||
return False
|
||
self._hits.append(now)
|
||
return True
|
||
|
||
|
||
async def _ws_session_still_valid_v3(token: str, exp_ts, recheck: bool, state: dict) -> bool:
|
||
"""v2 와 동일하되 재확인 조회 연속 실패가 _WS_RECHECK_MAX_FAILURES 회에 이르면 False.
|
||
|
||
일시 장애(연속 실패 < 상한)는 유지하고, 장기 DB 장애 중 비활성화된 계정은 끊는다.
|
||
성공하면 카운터를 리셋한다. state 는 연결별 dict({"fails": int}).
|
||
"""
|
||
if exp_ts is not None and time.time() >= exp_ts:
|
||
return False
|
||
if not recheck:
|
||
return True
|
||
from app.core.security import resolve_credential_v2
|
||
try:
|
||
ok = (await resolve_credential_v2(token)) is not None
|
||
except Exception as e:
|
||
state["fails"] = int(state.get("fails", 0)) + 1
|
||
if state["fails"] >= int(_WS_RECHECK_MAX_FAILURES):
|
||
logger.warning(f"[WS] 세션 재확인 연속 {state['fails']}회 실패 — 연결 종료: {e}")
|
||
return False
|
||
logger.warning(f"[WS] 세션 재확인 조회 실패({state['fails']}/{_WS_RECHECK_MAX_FAILURES}) — 연결 유지: {e}")
|
||
return True
|
||
state["fails"] = 0
|
||
return ok
|
||
|
||
|
||
@app.websocket("/ws/{client_id}")
|
||
async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||
"""WebSocket 연결 엔드포인트.
|
||
|
||
인증: 쿼리 파라미터 ?token= 또는 첫 메시지로 {"type": "auth", "token": "..."}
|
||
개발 모드(DEBUG=true)에서는 인증 없이 접속 허용.
|
||
|
||
클라이언트→서버 지원 프로토콜:
|
||
{"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", "stock_code", "period",
|
||
"data", "is_sample"(항상 False), "reason", "error"}
|
||
(백그라운드 응답 — 수신 루프 비차단)
|
||
그 외 메시지 → echo
|
||
"""
|
||
# WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용)
|
||
# 토큰 전달: Sec-WebSocket-Protocol("autostock.auth", <token>) 우선, ?token= 은 하위호환(추후 제거).
|
||
from app.core.security import extract_ws_subprotocol_token
|
||
_sp_token, _ws_subprotocol = extract_ws_subprotocol_token(
|
||
websocket.headers.get("sec-websocket-protocol")
|
||
)
|
||
_ws_token = ""
|
||
_ws_owner = "debug"
|
||
if not settings.DEBUG:
|
||
from app.core.security import resolve_credential_v2
|
||
_ws_token = _sp_token or websocket.query_params.get("token", "")
|
||
# [H-2] 세션 사용자의 is_active 까지 확인 (revoke 후 재연결 차단)
|
||
# DB-READ-EXCEPTION: 핸드셰이크 1회, 캐시 미스일 때만 users SELECT(commit 없음) —
|
||
# security._fetch_session_user_flags_impl. 연결 후 메시지 루프에서는 DB 를 읽지 않는다.
|
||
try:
|
||
_cred = await resolve_credential_v2(_ws_token) if _ws_token else None
|
||
except Exception as _ws_auth_err: # 사용자 상태 조회 불가(503) — 접속 거부(재연결로 회복)
|
||
logger.warning(f"[WS] 인증 상태 확인 실패 — 접속 거부: {_ws_auth_err}")
|
||
_cred = None
|
||
if _cred is None:
|
||
await websocket.close(code=4001, reason="Unauthorized")
|
||
return
|
||
_ws_owner = _cred.username
|
||
# [하드닝 R3] client_id 는 인증 주체에 묶는다 — 다른 사용자의 client_id 로 접속해
|
||
# 그 연결을 교체(connect_v2 는 같은 client_id 를 무조건 교체)하는 것을 막는다.
|
||
if not _ws_owner_may_connect_v2(client_id, _ws_owner, websocket_manager.active_connections):
|
||
logger.warning(f"[WS] client_id={client_id} 는 다른 인증 주체 소유 — 접속 거부(user={_ws_owner})")
|
||
await websocket.close(code=4003, reason="Forbidden")
|
||
return
|
||
_ws_exp = _ws_token_exp_v2(_ws_token) if _ws_token else None
|
||
_ws_last_recheck = time.monotonic()
|
||
_ws_recheck_state = {"fails": 0}
|
||
_chart_limiter = _WsChartLimiterV2(_WS_CHART_PER_SEC, _WS_CHART_PER_MIN)
|
||
|
||
await websocket_manager.connect_v2(websocket, client_id, subprotocol=_ws_subprotocol or None)
|
||
_ws_owners[client_id] = _ws_owner
|
||
|
||
# 연결 즉시 보유종목 자동 구독 (set_subscriptions 누락 대비 폴백)
|
||
async def _auto_subscribe_portfolio(cid: str):
|
||
try:
|
||
from app.api.endpoints.portfolio import portfolio_cache
|
||
holdings = portfolio_cache.holdings
|
||
if holdings:
|
||
codes = [h.stock_code for h in holdings if h.stock_code and h.quantity > 0]
|
||
if codes:
|
||
websocket_manager.subscribe(cid, codes)
|
||
from app.services.kis_realtime_stream import kis_stream
|
||
kis_stream.notify_subscription_change()
|
||
logger.info(f"[auto-sub] {cid}: 보유종목 {len(codes)}개 자동 구독 → {codes[:5]}...")
|
||
except Exception as e:
|
||
logger.warning(f"[auto-sub] 보유종목 자동 구독 실패: {e}")
|
||
_spawn_background(_auto_subscribe_portfolio(client_id))
|
||
|
||
def _notify_kis_subscription_change() -> None:
|
||
# [H-5] 구독 변경 즉시 KIS 동기화 태스크를 깨운다 (수신 프레임 유무와 무관)
|
||
from app.services.kis_realtime_stream import kis_stream
|
||
kis_stream.notify_subscription_change()
|
||
|
||
# [M-6] get_chart 는 백그라운드 태스크로 응답 — 수신 루프(ping/구독 처리)를 막지 않는다.
|
||
chart_tasks: dict = {}
|
||
|
||
async def _send_chart_response(code: str, period, limit_raw) -> None:
|
||
from app.api.endpoints.market_data import build_ws_chart_response_v2
|
||
resp = await build_ws_chart_response_v2(code, period, limit_raw)
|
||
if websocket_manager.active_connections.get(client_id) is not websocket:
|
||
return # 그 사이 연결이 교체/종료됨
|
||
logger.info(f"[get_chart] {code}/{period} → {len(resp['data'])} candles"
|
||
f"{' reason=' + str(resp['reason']) if resp.get('reason') else ''} → {client_id}")
|
||
try:
|
||
await websocket.send_text(_dumps(resp))
|
||
except Exception as send_err:
|
||
logger.warning(f"[get_chart] {client_id} 응답 전송 실패: {send_err}")
|
||
|
||
try:
|
||
while True:
|
||
# 이 핸들러의 websocket이 active_connections에서 교체되었으면 종료
|
||
if websocket_manager.active_connections.get(client_id) is not websocket:
|
||
logger.info(f"Client {client_id}: 교체/해제된 연결 감지, 핸들러 종료")
|
||
break
|
||
|
||
# [하드닝 R3] 핸드셰이크 이후 세션 만료·계정 비활성화 재확인
|
||
if not settings.DEBUG:
|
||
_now_m = time.monotonic()
|
||
_due = (_now_m - _ws_last_recheck) >= float(_WS_RECHECK_INTERVAL_SEC)
|
||
if _due:
|
||
_ws_last_recheck = _now_m
|
||
if not await _ws_session_still_valid_v3(_ws_token, _ws_exp, _due, _ws_recheck_state):
|
||
logger.warning(f"[WS] {client_id} 세션 만료/비활성 — 연결 종료(4001)")
|
||
with contextlib.suppress(Exception):
|
||
await websocket.close(code=4001, reason="Session expired")
|
||
break
|
||
|
||
try:
|
||
data = await asyncio.wait_for(websocket.receive_text(), timeout=60.0)
|
||
except asyncio.TimeoutError:
|
||
# 60초 동안 메시지 없음 → ping 전송으로 연결 유지 확인
|
||
try:
|
||
await websocket.send_text(_dumps({"type": "ping"}))
|
||
except Exception:
|
||
break
|
||
continue
|
||
try:
|
||
msg = json.loads(data)
|
||
except (json.JSONDecodeError, AttributeError):
|
||
try:
|
||
await websocket.send_text(f"Echo: {data}")
|
||
except Exception:
|
||
break
|
||
continue
|
||
if not isinstance(msg, dict):
|
||
msg = {}
|
||
|
||
msg_type = msg.get("type", "")
|
||
|
||
if msg_type == "ping":
|
||
try:
|
||
await websocket.send_text(_dumps({"type": "pong"}))
|
||
except Exception:
|
||
break
|
||
continue
|
||
|
||
if msg_type == "subscribe":
|
||
codes = _ws_cap_codes_v2(msg.get("stock_codes") or [])
|
||
websocket_manager.subscribe(client_id, codes)
|
||
_notify_kis_subscription_change()
|
||
await websocket_manager.send_personal_message(
|
||
_dumps({
|
||
"type": "ack", "action": "subscribe",
|
||
"stock_codes": codes,
|
||
"subscriptions": websocket_manager.get_subscriptions(client_id),
|
||
}),
|
||
client_id,
|
||
)
|
||
continue
|
||
|
||
if msg_type == "unsubscribe":
|
||
codes = _ws_cap_codes_v2(msg.get("stock_codes") or [])
|
||
websocket_manager.unsubscribe(client_id, codes)
|
||
_notify_kis_subscription_change()
|
||
await websocket_manager.send_personal_message(
|
||
_dumps({
|
||
"type": "ack", "action": "unsubscribe",
|
||
"stock_codes": codes,
|
||
"subscriptions": websocket_manager.get_subscriptions(client_id),
|
||
}),
|
||
client_id,
|
||
)
|
||
continue
|
||
|
||
if msg_type == "set_subscriptions":
|
||
codes = _ws_cap_codes_v2(msg.get("stock_codes") or [])
|
||
logger.info(f"[WS] {client_id}: set_subscriptions → {codes}")
|
||
websocket_manager.set_subscriptions(client_id, codes)
|
||
# 선택 종목을 명시하면(선택 필드) KIS 등록 우선순위·호가 대상에 반영
|
||
if "selected_code" in msg:
|
||
_sel = str(msg.get("selected_code") or "").strip()
|
||
websocket_manager.set_selected_codes_v2(client_id, [_sel] if _sel else [])
|
||
# 구독 목록 변경 → KIS 스트림에 즉시 동기화 요청
|
||
_notify_kis_subscription_change()
|
||
try:
|
||
await websocket.send_text(_dumps({
|
||
"type": "ack", "action": "set_subscriptions",
|
||
"stock_codes": codes,
|
||
"subscriptions": websocket_manager.get_subscriptions(client_id),
|
||
}))
|
||
except Exception:
|
||
break
|
||
# 구독 종목 차트 캐시 사전 워밍업 (백그라운드, 논블로킹)
|
||
if codes:
|
||
async def _warmup_chart_cache(stock_codes: list):
|
||
from app.api.endpoints.market_data import (
|
||
get_chart_data_cached_v2, is_chart_cache_fresh_v2,
|
||
)
|
||
for code in stock_codes:
|
||
# 엔트리 자체 ttl(장중 합성 30초 / 그 외 10분) 존중
|
||
if is_chart_cache_fresh_v2(code, "D", 200):
|
||
continue
|
||
try:
|
||
await get_chart_data_cached_v2(code, "D", 200)
|
||
except Exception as e:
|
||
warn_throttled(logger, "main:767", f"[warmup] {code} 캐시 실패: {e}")
|
||
_spawn_background(_warmup_chart_cache(codes))
|
||
continue
|
||
|
||
if msg_type == "get_chart":
|
||
stock_code = str(msg.get("stock_code", "") or "").strip()
|
||
period = msg.get("period", "D")
|
||
limit_raw = msg.get("limit", 200)
|
||
if not stock_code:
|
||
continue
|
||
if not _chart_limiter.allow():
|
||
logger.warning(f"[get_chart] {client_id} 요청 빈도 초과 — 무시: {stock_code}")
|
||
continue
|
||
# 차트를 보는 종목 = 선택 종목 (KIS 등록 우선순위·호가 구독 대상, H-4)
|
||
websocket_manager.set_selected_codes_v2(client_id, [stock_code])
|
||
# 자동 구독 폴백: 요청 종목이 구독 목록에 없으면 추가
|
||
if stock_code not in websocket_manager.get_subscriptions(client_id):
|
||
websocket_manager.subscribe(client_id, [stock_code])
|
||
logger.info(f"[auto-sub] {client_id}: get_chart 요청으로 {stock_code} 자동 구독 추가")
|
||
_notify_kis_subscription_change()
|
||
task_key = (stock_code, str(period), str(limit_raw))
|
||
prev = chart_tasks.get(task_key)
|
||
if prev is not None and not prev.done():
|
||
continue # 동일 요청 처리 중 — 그 응답이 곧 간다
|
||
task = _spawn_background(_send_chart_response(stock_code, period, limit_raw))
|
||
chart_tasks[task_key] = task
|
||
task.add_done_callback(
|
||
lambda t, k=task_key: chart_tasks.pop(k, None) if chart_tasks.get(k) is t else None
|
||
)
|
||
continue
|
||
|
||
await websocket_manager.send_personal_message(f"Echo: {data}", client_id)
|
||
|
||
except WebSocketDisconnect as e:
|
||
logger.info(f"Client {client_id} disconnected (code={e.code})")
|
||
except Exception as e:
|
||
logger.error(f"Client {client_id} unexpected WS error: {type(e).__name__}: {e}")
|
||
finally:
|
||
# [L-2] break 경로 포함 모든 종료에서 정리. 동일 client_id 로 새 연결이 교체됐다면
|
||
# 새 연결까지 제거하지 않도록 현재 websocket 일 때만 disconnect.
|
||
for _t in list(chart_tasks.values()):
|
||
_t.cancel()
|
||
chart_tasks.clear()
|
||
if websocket_manager.active_connections.get(client_id) is websocket:
|
||
_ws_owners.pop(client_id, None)
|
||
websocket_manager.disconnect(client_id)
|
||
try:
|
||
_notify_kis_subscription_change()
|
||
except Exception as _notify_err:
|
||
logger.warning(f"[WS] {client_id} 종료 후 KIS 구독 동기화 요청 실패: {_notify_err}")
|
||
|
||
@app.post("/api/admin/reset-daily-loss", dependencies=[Depends(_require_admin_v2)])
|
||
async def reset_daily_loss(api_key: str = Depends(_require_api_key)):
|
||
"""DT 당일 실현손익(일일 손실 한도 기준) 리셋 (인증 필수).
|
||
|
||
[하드닝 R2] 예전에는 mdd_manager.reset() 을 불러 MDD peak 를 현재 잔고로 낮춰
|
||
-10%/-20% 낙폭 기준선을 조용히 소거했다. MDD 리셋은 /risk/mdd/reset 에서만 한다.
|
||
"""
|
||
from app.services.daytrading.position_manager import day_trading_state
|
||
async with day_trading_state.trade_lock:
|
||
prev = day_trading_state.daily_pnl
|
||
day_trading_state.daily_pnl = 0.0
|
||
logger.warning(f"[admin] DT 일일 손익 리셋: {prev:+,.0f} -> 0 (MDD 는 건드리지 않음)")
|
||
return {"status": "ok", "message": "daytrading daily pnl reset (MDD untouched)", "previous_daily_pnl": prev}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(
|
||
"app.main:app",
|
||
host=settings.BACKEND_HOST,
|
||
port=settings.BACKEND_PORT,
|
||
reload=settings.DEBUG,
|
||
)
|