fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 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>
This commit is contained in:
@@ -21,13 +21,14 @@ 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
|
||||
from app.core.market_schedule import is_market_open, is_krx_holiday
|
||||
|
||||
# 로깅 설정
|
||||
import os
|
||||
@@ -51,8 +52,80 @@ for _h in _handlers:
|
||||
|
||||
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,
|
||||
@@ -78,6 +151,19 @@ async def lifespan(app: FastAPI):
|
||||
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` 를 실행할 것.
|
||||
@@ -139,15 +225,22 @@ async def lifespan(app: FastAPI):
|
||||
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.in_(['PENDING', 'FAILED']),
|
||||
TradeHistory.status == 'FAILED',
|
||||
TradeHistory.created_at < cutoff_datetime,
|
||||
)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🗑️ Deleted {result.rowcount} old PENDING/FAILED trades")
|
||||
logger.info(f"🗑️ Deleted {result.rowcount} old FAILED trades")
|
||||
|
||||
result = await session.execute(
|
||||
delete(Portfolio).where(Portfolio.quantity <= 0)
|
||||
@@ -207,7 +300,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("✅ [KRX] 초기 데이터 수집 완료")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [KRX] 초기 데이터 수집 실패 (non-critical): {e}")
|
||||
asyncio.create_task(_initial_krx_collect())
|
||||
_spawn_background(_initial_krx_collect())
|
||||
logger.info("📥 [KRX] stock_daily_prices 비어있음 — 백그라운드 초기 수집 예약됨")
|
||||
elif _total == 0:
|
||||
logger.warning("⚠️ [KRX] stock_daily_prices 비어있으나 DATAGOKR_API_KEY 미설정 — 네이버 차트 폴백 사용")
|
||||
@@ -251,8 +344,8 @@ async def lifespan(app: FastAPI):
|
||||
category="DB_WORKER",
|
||||
message=f"db_worker 크래시 후 자동 재시작: {type(exc).__name__}: {exc}",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
@@ -271,6 +364,28 @@ async def lifespan(app: FastAPI):
|
||||
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
|
||||
@@ -332,8 +447,8 @@ async def lifespan(app: FastAPI):
|
||||
today = now.date()
|
||||
hm = now.hour * 100 + now.minute # HHMM 형식
|
||||
|
||||
# 평일만 (월~금)
|
||||
if now.weekday() >= 5:
|
||||
# 평일 + KRX 개장일만 (공휴일·연말 휴장·근로자의날 제외 — L-4)
|
||||
if now.weekday() >= 5 or is_krx_holiday(today):
|
||||
await asyncio.sleep(300)
|
||||
continue
|
||||
|
||||
@@ -373,7 +488,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"🚨 [AI Strategy] 뉴스 악재 감지: {len(alerts)}건")
|
||||
_last_news_check = now
|
||||
except Exception as e:
|
||||
logger.debug(f"[AI Strategy] 뉴스 모니터링 실패: {e}")
|
||||
warn_throttled(logger, "main:376", f"[AI Strategy] 뉴스 모니터링 실패: {e}")
|
||||
|
||||
# 15:40 — 장 후 리뷰
|
||||
if 1540 <= hm < 1600 and _post_market_done_today != today:
|
||||
@@ -420,6 +535,9 @@ async def lifespan(app: FastAPI):
|
||||
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",
|
||||
@@ -427,7 +545,6 @@ async def lifespan(app: FastAPI):
|
||||
"krx_collector_task",
|
||||
"post_market_task",
|
||||
"journal_intraday_task",
|
||||
"db_worker_task",
|
||||
"kis_stream_task",
|
||||
"us_trade_task",
|
||||
"news_collect_task",
|
||||
@@ -435,7 +552,15 @@ async def lifespan(app: FastAPI):
|
||||
"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:
|
||||
@@ -500,7 +625,7 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
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"],
|
||||
)
|
||||
@@ -520,7 +645,10 @@ _rate_limit_counter = 0 # cleanup 트리거 카운터
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
"""IP 기반 간이 rate limiter — 인증 없는 엔드포인트 DoS 방어."""
|
||||
global _rate_limit_counter
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
# [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 제외
|
||||
@@ -602,9 +730,32 @@ 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 연결 상태를 포함한 헬스체크."""
|
||||
"""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
|
||||
|
||||
@@ -615,22 +766,34 @@ async def health_check():
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(_sa_text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
pass
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -652,9 +815,17 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
"""
|
||||
# WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용)
|
||||
if not settings.DEBUG:
|
||||
from app.core.security import is_valid_credential
|
||||
from app.core.security import is_valid_credential_v2
|
||||
token = websocket.query_params.get("token", "")
|
||||
if not token or not is_valid_credential(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
|
||||
|
||||
@@ -673,8 +844,8 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
kis_stream.notify_subscription_change()
|
||||
logger.info(f"[auto-sub] {cid}: 보유종목 {len(codes)}개 자동 구독 → {codes[:5]}...")
|
||||
except Exception as e:
|
||||
logger.debug(f"[auto-sub] 보유종목 자동 구독 실패: {e}")
|
||||
asyncio.create_task(_auto_subscribe_portfolio(client_id))
|
||||
logger.warning(f"[auto-sub] 보유종목 자동 구독 실패: {e}")
|
||||
_spawn_background(_auto_subscribe_portfolio(client_id))
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -764,8 +935,8 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
await get_chart_data_cached(code, "D", 200)
|
||||
logger.debug(f"[warmup] {code} 차트 캐시 완료")
|
||||
except Exception as e:
|
||||
logger.debug(f"[warmup] {code} 캐시 실패: {e}")
|
||||
asyncio.create_task(_warmup_chart_cache(codes))
|
||||
warn_throttled(logger, "main:767", f"[warmup] {code} 캐시 실패: {e}")
|
||||
_spawn_background(_warmup_chart_cache(codes))
|
||||
continue
|
||||
|
||||
if msg_type == "get_chart":
|
||||
@@ -811,7 +982,7 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
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")
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user