Files
auto-stock/backend/app/main.py
shuim 2e1a6e688c fix(health): KIS 스트림 무수신 경보를 등록 0건(대시보드 미접속)일 때 억제
스트림은 대시보드 구독 종목만 등록하므로 브라우저가 닫히면 수신 0이 정상이다.
10/12 장중 오경보 3건(12:54·13:29·13:46)이 모두 구독 해제 10분 뒤였다.
재구독 직후에는 등록 시각을 기준점으로 써 옛 last_msg 로 즉시 오경보하지 않게 했다.
태스크 사망은 등록 수와 무관하게 계속 경보한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-12 13:52:01 +09:00

1852 lines
90 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}
# 등록 0건 → 1건 이상으로 바뀐 시각(monotonic). 재구독 직후 옛 last_msg 로 오경보하지 않게 기준점으로 쓴다.
_kis_stream_regs_since: float | None = None
def _kis_stream_registrations_v2() -> int | None:
try:
from app.services.kis_realtime_stream import kis_stream
return len(kis_stream._registered)
except Exception as _swallowed:
warn_throttled(logger, "main:ks_regs", "[health] KIS 등록 수 확인 실패: %s", _swallowed)
return None
def _kis_stream_health_v3(application, now: float | None = None, market_open: bool | None = None,
registrations: int | None = -1) -> dict | None:
"""v2 + 등록 0건이면 무수신을 정상으로 본다(2026-10-12).
스트림은 대시보드 구독 종목만 KIS 에 등록한다(매매 경로는 스트림을 쓰지 않는다).
브라우저가 닫혀 등록이 0건이면 수신이 없는 것이 정상인데, v2 는 이를 장중 무수신으로
error·텔레그램 경보했다(10/12 12:54·13:29·13:46 — 전부 구독 해제 10분 뒤).
태스크 사망은 등록 수와 무관하게 계속 silent 다. 등록 수를 알 수 없으면(None) v2 판정을 따른다.
"""
global _kis_stream_regs_since
base = _kis_stream_health_v2(application, now=now, market_open=market_open)
if base is None:
return None
regs = _kis_stream_registrations_v2() if registrations == -1 else registrations
n = now if now is not None else time.monotonic()
base["registrations"] = regs
if regs is None:
return base
if regs <= 0:
_kis_stream_regs_since = None
if base["alive"]:
base["silent"] = False
return base
if _kis_stream_regs_since is None:
_kis_stream_regs_since = n
if base["silent"] and base["alive"]:
last = _kis_stream_last_msg_mono
ref = max(last if last is not None else -1e18, _kis_stream_regs_since)
base["silent"] = (n - ref) > float(_HB_KIS_STREAM_SILENT_SECONDS)
base["last_msg_age"] = round(n - ref, 1)
return base
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_v3(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_v3(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,
)