하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1001 lines
49 KiB
Python
1001 lines
49 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_admin_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
|
||
|
||
|
||
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)
|
||
|
||
|
||
# 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,
|
||
)
|
||
|
||
|
||
@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."
|
||
)
|
||
|
||
try:
|
||
from app.services.loss_tracker import loss_tracker
|
||
async with AsyncSessionLocal() as session:
|
||
await loss_tracker.load_from_db(session)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ loss_tracker 초기화 실패 (non-critical): {e}")
|
||
|
||
# ── 전략 파라미터 DB 초기화 (config.py 기본값 → strategy_configs 테이블 seed) ──
|
||
try:
|
||
from app.services.strategy_config_service import strategy_cfg
|
||
await strategy_cfg.auto_init()
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ strategy_cfg 초기화 실패 (non-critical): {e}")
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
try:
|
||
from app.models.models import StockAnalysis, TradeHistory, Portfolio
|
||
from sqlalchemy import delete
|
||
from datetime import datetime, timedelta
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
_kst = pytz.timezone("Asia/Seoul")
|
||
_today_kst = datetime.now(_kst).date()
|
||
cutoff_date = _today_kst - timedelta(days=7)
|
||
result = await session.execute(
|
||
delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date)
|
||
)
|
||
if result.rowcount > 0:
|
||
logger.info(f"🗑️ Deleted {result.rowcount} old stock_analysis records")
|
||
|
||
# [M-1 2026-10-02] PENDING 은 삭제하지 않는다. KIS 주문접수 후 체결 확인이
|
||
# 실패한 BUY 는 영구 PENDING 으로 남는데, 그 행이 유입잔고 강등(swing→external)
|
||
# 방어의 근거다(portfolio.py `_NON_FILL_BUY_STATUSES` 주석). 지우면 재기동마다
|
||
# 정상 보유 포지션이 강등 후보가 된다. 당일 판정 쿼리는 모두 created_at>=오늘
|
||
# 조건이라 오래된 PENDING 이 남아도 매매 판정에 영향이 없다.
|
||
# FAILED(주문 거부, 주식 미보유)만 1일 경과분을 정리한다 — 기동 시 1회의
|
||
# 부트스트랩 정리이며 실시간 계층이 아니다.
|
||
cutoff_datetime = _today_kst - timedelta(days=1)
|
||
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
|
||
restored = await mdd_manager.load_from_redis()
|
||
if not restored:
|
||
logger.info("ℹ️ MDD Redis 상태 없음 — 초기 상태로 시작")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ MDD Redis 복원 실패 (non-critical): {e}")
|
||
|
||
# 워커 + 스케줄러 시작 (C5: 자동 재시작 콜백 포함)
|
||
def _restart_db_worker(task: asyncio.Task):
|
||
if task.cancelled():
|
||
return
|
||
exc = task.exception()
|
||
if exc:
|
||
logger.error(f"db_worker 크래시: {exc}, 자동 재시작...")
|
||
# Telegram 시스템 경고
|
||
try:
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
asyncio.ensure_future(telegram_notifier.notify_system_warning(
|
||
category="DB_WORKER",
|
||
message=f"db_worker 크래시 후 자동 재시작: {type(exc).__name__}: {exc}",
|
||
))
|
||
except Exception as _swallowed:
|
||
logger.warning("[main] db_worker 재시작 텔레그램 알림 실패: %s", _swallowed)
|
||
new_task = asyncio.create_task(db_writer_worker())
|
||
new_task.add_done_callback(_restart_db_worker)
|
||
app.state.db_worker_task = new_task
|
||
|
||
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
|
||
app.state.swing_trade_task = asyncio.create_task(swing_scheduler())
|
||
app.state.day_trade_task = asyncio.create_task(daytrading_scheduler())
|
||
|
||
# 배치 분석 / 일봉 수집 / 장 후 분석 (기존 유지)
|
||
app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler())
|
||
app.state.krx_collector_task = asyncio.create_task(krx_data_collector_scheduler())
|
||
app.state.post_market_task = asyncio.create_task(post_market_analyzer_scheduler())
|
||
app.state.journal_intraday_task = asyncio.create_task(journal_intraday_scheduler())
|
||
|
||
# ── 미국장 자동매매 스케줄러 (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())
|
||
logger.info("📡 KIS 실시간 스트림 태스크 시작 (KIS_WS_ENABLED=True)")
|
||
else:
|
||
logger.info("⏭️ KIS 실시간 스트림 비활성화됨 (KIS_WS_ENABLED=False in .env)")
|
||
app.state.kis_stream_task = None
|
||
|
||
yield
|
||
|
||
# 태스크 정리
|
||
# [H-5 2026-10-02] db_worker 는 **마지막에** 멈춘다. 이전에는 생산자(us_trade·news·
|
||
# adaptive·ai_strategy 등)보다 먼저 cancel 돼, 그 뒤 생산자가 종료 과정에서 넣은 쓰기가
|
||
# drain 되지 못하고 유실됐다. 생산자를 모두 멈춘 뒤 워커가 큐를 비우게 한다.
|
||
for task_attr in (
|
||
"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",
|
||
"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 로 직접 보관한다.
|
||
await _await_background_tasks(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
|
||
|
||
|
||
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"
|
||
# [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}
|
||
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,
|
||
}
|
||
|
||
|
||
@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", ...}
|
||
그 외 메시지 → echo
|
||
"""
|
||
# WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용)
|
||
if not settings.DEBUG:
|
||
from app.core.security import is_valid_credential_v2
|
||
token = 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:
|
||
_ws_ok = bool(token) and await is_valid_credential_v2(token)
|
||
except Exception as _ws_auth_err: # 사용자 상태 조회 불가(503) — 접속 거부(재연결로 회복)
|
||
logger.warning(f"[WS] 인증 상태 확인 실패 — 접속 거부: {_ws_auth_err}")
|
||
_ws_ok = False
|
||
if not _ws_ok:
|
||
await websocket.close(code=4001, reason="Unauthorized")
|
||
return
|
||
|
||
await websocket_manager.connect(websocket, client_id)
|
||
|
||
# 연결 즉시 보유종목 자동 구독 (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))
|
||
|
||
try:
|
||
while True:
|
||
# 이 핸들러의 websocket이 active_connections에서 교체되었으면 종료
|
||
if websocket_manager.active_connections.get(client_id) is not websocket:
|
||
logger.debug(f"Client {client_id}: 교체된 연결 감지, 이전 핸들러 종료")
|
||
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
|
||
|
||
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 = [str(c) for c in msg.get("stock_codes", [])]
|
||
websocket_manager.subscribe(client_id, codes)
|
||
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 = [str(c) for c in msg.get("stock_codes", [])]
|
||
websocket_manager.unsubscribe(client_id, codes)
|
||
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 = [str(c) for c in msg.get("stock_codes", [])]
|
||
logger.info(f"[WS] {client_id}: set_subscriptions → {codes}")
|
||
websocket_manager.set_subscriptions(client_id, codes)
|
||
# 구독 목록 변경 → KIS 스트림에 즉시 동기화 요청
|
||
from app.services.kis_realtime_stream import kis_stream
|
||
kis_stream.notify_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, chart_cache
|
||
from datetime import datetime
|
||
for code in stock_codes:
|
||
cached = chart_cache.get(f"{code}__D__200")
|
||
if cached and (datetime.now() - cached["timestamp"]).total_seconds() < 600:
|
||
continue
|
||
try:
|
||
await get_chart_data_cached(code, "D", 200)
|
||
logger.debug(f"[warmup] {code} 차트 캐시 완료")
|
||
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", "")).strip()
|
||
period = msg.get("period", "D")
|
||
limit = int(msg.get("limit", 200))
|
||
if not stock_code:
|
||
continue
|
||
# 자동 구독 폴백: 클라이언트에 구독 종목이 없으면 요청 종목 자동 구독
|
||
current_subs = websocket_manager.get_subscriptions(client_id)
|
||
if stock_code not in current_subs:
|
||
websocket_manager.subscribe(client_id, [stock_code])
|
||
from app.services.kis_realtime_stream import kis_stream
|
||
kis_stream.notify_subscription_change()
|
||
logger.info(f"[auto-sub] {client_id}: get_chart 요청으로 {stock_code} 자동 구독 추가")
|
||
try:
|
||
from app.api.endpoints.market_data import get_chart_data_cached
|
||
chart_data = await get_chart_data_cached(stock_code, period, limit)
|
||
logger.info(f"[get_chart] {stock_code}/{period} → {len(chart_data)} candles → sending to {client_id}")
|
||
await websocket.send_text(
|
||
_dumps({"type": "chart_data", "stock_code": stock_code, "period": period, "data": chart_data}),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"get_chart 오류 ({stock_code}): {e}")
|
||
try:
|
||
await websocket.send_text(
|
||
_dumps({"type": "chart_data", "stock_code": stock_code, "period": period, "data": [], "error": str(e)}),
|
||
)
|
||
except Exception:
|
||
break
|
||
continue
|
||
|
||
await websocket_manager.send_personal_message(f"Echo: {data}", client_id)
|
||
|
||
except WebSocketDisconnect as e:
|
||
# 동일 client_id로 새 연결이 교체되었을 경우, 이전 핸들러가 disconnect하면 새 연결까지 제거됨
|
||
# 현재 websocket이 active_connections에 있는 것과 동일한 경우에만 disconnect
|
||
if websocket_manager.active_connections.get(client_id) is websocket:
|
||
websocket_manager.disconnect(client_id)
|
||
logger.info(f"Client {client_id} disconnected (code={e.code})")
|
||
except Exception as e:
|
||
if websocket_manager.active_connections.get(client_id) is websocket:
|
||
websocket_manager.disconnect(client_id)
|
||
logger.error(f"Client {client_id} unexpected WS error: {type(e).__name__}: {e}")
|
||
|
||
@app.post("/api/admin/reset-daily-loss", dependencies=[Depends(_require_admin_v2)])
|
||
async def reset_daily_loss(api_key: str = Depends(_require_admin_key)):
|
||
"""테스트용 일일 손실 한도 강제 리셋 (인증 필수)"""
|
||
from app.services.mdd_manager import mdd_manager
|
||
await mdd_manager.reset()
|
||
return {"status": "ok", "message": "daily loss limit reset"}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(
|
||
"app.main:app",
|
||
host=settings.BACKEND_HOST,
|
||
port=settings.BACKEND_PORT,
|
||
reload=settings.DEBUG,
|
||
)
|