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:
@@ -39,6 +39,43 @@ class Settings(BaseSettings):
|
||||
BOOTSTRAP_ADMIN_USERNAME: str = "admin" # 앱 최초 기동 시 users 테이블이 비어있으면 시드할 관리자 계정명
|
||||
BOOTSTRAP_ADMIN_PASSWORD: str = "" # 비어있으면 부트스트랩 관리자 시드 건너뜀
|
||||
|
||||
# ── 인증·보안 강화 (2026-10-02 검증 루프 2회차) ─────────────────────
|
||||
# 세션 토큰의 사용자 is_active/is_admin 재확인 캐시 TTL(초). revoke 후 최대 이 시간만 통과.
|
||||
SESSION_ACTIVE_CACHE_SECONDS: int = 30
|
||||
# SECRET_KEY/API_KEY 최소 길이 (CLAUDE.md §4: 64자+ 랜덤)
|
||||
MIN_SECRET_KEY_LENGTH: int = 64
|
||||
# True 이고 DEBUG=False 이면 약한/기본 키로 기동을 중단한다(fail-closed).
|
||||
# 기본 False: 운영 .env 상태 확인 전에는 error 로그 + /health "security":"warn" 만 낸다.
|
||||
SECURITY_FAIL_CLOSED: bool = False
|
||||
# X-Real-IP 를 신뢰할 직전 홉(프록시) 대역. 이 대역 밖에서 온 요청은 소켓 주소를 쓴다.
|
||||
TRUSTED_PROXY_CIDRS: str = "127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
# holidays 패키지에 없는 KRX 임시 휴장일 (YYYY-MM-DD 콤마 구분)
|
||||
KRX_EXTRA_HOLIDAYS: str = ""
|
||||
# KIS 토큰 재발급 최소 간격(초) — KIS "1분당 1회" 제한 + 여유. 이 창 안에서는 대기하지 않고 즉시 실패한다.
|
||||
KIS_TOKEN_REISSUE_MIN_INTERVAL_SEC: int = 70
|
||||
# KIS 토큰 실제 유효기간(시간). 로컬 만료(23h)보다 길다 — 재발급 실패 시 이 한도 안에서만 기존 토큰을 연장한다.
|
||||
KIS_TOKEN_HARD_TTL_HOURS: int = 24
|
||||
# 재발급 실패 시 기존 토큰 연장 폭(분) — 짧게 잡아 실제 만료 전에 재발급을 다시 시도하게 한다.
|
||||
KIS_TOKEN_GRACE_EXTEND_MINUTES: int = 10
|
||||
# 종료 시 db_worker 를 멈추기 전에 백그라운드 태스크(_spawn_background)를 기다리는 상한(초).
|
||||
# uvicorn 정리 + 이 값 + db_worker drain(DRAIN_DEADLINE_SECONDS=20) 이 compose stop_grace_period(40s) 안에
|
||||
# 들어와야 한다 — 기동 시 main.py 가 합계를 CONTAINER_STOP_GRACE_SECONDS 와 비교해 넘으면 error 로그.
|
||||
SHUTDOWN_BACKGROUND_DRAIN_SECONDS: int = 8
|
||||
# uvicorn --timeout-graceful-shutdown. backend/Dockerfile CMD 가 같은 이름의 **환경변수**를 읽어
|
||||
# 적용한다(기본 5) — .env 로 바꾸면 실제 반영된다.
|
||||
UVICORN_GRACEFUL_SHUTDOWN_SECONDS: int = 5
|
||||
# docker-compose.yml backend stop_grace_period 와 같은 값(초). 컨테이너 설정을 앱이 읽을 수 없으므로
|
||||
# 여기 값을 기준으로 종료 예산 합계를 검증한다(테스트가 compose 와 대조).
|
||||
CONTAINER_STOP_GRACE_SECONDS: int = 40
|
||||
# 기동 시 DLQ replay 로 재큐잉된 밴이 커밋되기를 기다리는 상한(초) — 그 뒤 loss_tracker 재로드
|
||||
DLQ_REPLAY_SETTLE_SECONDS: int = 10
|
||||
# /health 결과 캐시(초) — 폴링 폭주 시 DB 커넥션 풀 경쟁 방지
|
||||
HEALTH_CACHE_SECONDS: float = 1.5
|
||||
# Redis 소켓 타임아웃(초) — 없으면 Redis 가 응답 없이 멈출 때 호출자(DT finalize·분산락)가 무기한 대기.
|
||||
# 코드베이스에 블로킹 명령(BLPOP/pubsub)이 없어 읽기 타임아웃이 정상 동작을 끊지 않는다(2026-10-02 확인).
|
||||
REDIS_SOCKET_TIMEOUT: float = 5.0
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT: float = 3.0
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 데이터베이스 & 메시지 큐
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
@@ -179,6 +216,12 @@ class Settings(BaseSettings):
|
||||
# 몇 회 연속 갱신 실패부터 차단할지. 1~2회는 KIS 순간 오류로 흔하므로
|
||||
# 알림 임계값(swing/scheduler.MDD_UPDATE_FAILURE_ALERT)과 동일한 5회로 맞춘다.
|
||||
MDD_BLIND_FAILURE_THRESHOLD: int = 5
|
||||
# [C-1 2026-10-02] MDD 강제축소 종목별 재시도 상한 (KST 하루 단위).
|
||||
# 거래정지·주문가능 0 등으로 한 종목이 계속 실패해도 이미 축소된 종목은 재매도하지
|
||||
# 않는다(레벨별 완료 집합). 실패 종목만 재시도하고, 이 횟수에 도달하면 당일 포기 +
|
||||
# error 로그. 날짜가 바뀌면 시도 횟수만 초기화된다(완료 집합은 peak 갱신 시에만 초기화).
|
||||
# order_lock_busy(다른 경로가 같은 종목 주문 중)는 실패로 세지 않는다.
|
||||
MDD_REDUCTION_MAX_RETRIES: int = 5
|
||||
|
||||
# 가격 필터 (섹션 5.4)
|
||||
MIN_PRICE_KRW: int = 5000 # 국내 5,000원 이하 매수 금지
|
||||
@@ -230,6 +273,13 @@ class Settings(BaseSettings):
|
||||
# (3) 최대 연속 손실 횟수가 기존 경로 대비 악화되지 않음
|
||||
# — 3개를 모두 통과한 뒤에만 켠다. 하락장 진입을 늘리는 변경이므로 특히 위험.
|
||||
MEAN_REVERSION_BEAR_V2_ENABLED: bool = False # 기본 False = 기존 4중 AND 경로 그대로
|
||||
# [C1 2026-10-02] 추천 스캐너(recommendations.is_mean_reversion_candidate)의 RSI 임계
|
||||
# 출처 스위치. False(기본) = 현행: trading_rules(DB/JSON) conditions 의
|
||||
# rsi_bull_max/rsi_bear_max(없으면 40/30). True = CLAUDE.md §0 우선순위
|
||||
# (adaptive rsi_entry/bear_rsi_entry → strategy_cfg → settings 46/35).
|
||||
# 2026-10-02 사용자 결정(거래 확대 우선)으로 True — 진입 RSI 40/30 → 46/35.
|
||||
# ⚠️ 백테스트 미검증, 모의계좌 기준 결정. KIS_MODE=real 전환 전 재검토.
|
||||
MEAN_REVERSION_CANONICAL_PARAMS_ENABLED: bool = True
|
||||
# RSI 상한(필수 AND): 기존 임계값(35) + relax, 단 hard cap(40) 이하.
|
||||
# 근거: 40은 BULL 진입 임계(46)보다 낮게 유지해 "하락장은 더 보수적"이라는 원칙을 깨지 않는다.
|
||||
MEAN_REVERSION_BEAR_V2_RSI_RELAX: float = 5.0
|
||||
@@ -483,6 +533,18 @@ class Settings(BaseSettings):
|
||||
MAX_DAILY_TRADES: int = 5
|
||||
API_TRIGGERED_AUTO_TRADE: bool = False # GET /recommendations/rule-based?auto_trade=true 가 매매를 실행할지. 기본 OFF — 매매는 스윙 스케줄러 단일 경로
|
||||
MAX_DAILY_BUYS_PER_STOCK: int = 2 # 종목당 일일 최대 매수 횟수
|
||||
# [M-1 3회차 2026-10-02] 자동매매 1사이클 후보 순회. 매수 성공 건수가 한도
|
||||
# (MAX_DAILY_TRADES 잔여)에 도달할 때까지 후보를 계속 평가한다 — SKIP/HOLD 는 슬롯을
|
||||
# 쓰지 않는다. 아래는 KIS 호출량 보호용 사이클당 '신규 후보' 평가 상한(보유 종목의
|
||||
# 청산 판정은 상한과 무관하게 계속).
|
||||
AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE: int = 30
|
||||
# KIS 업무 거부(rt_cd "1": 잔고·호가·종목 상태 등) 매수 종목은 이 시간 동안 재주문하지
|
||||
# 않는다(인메모리, 재기동 시 초기화). 하루 차단이 아니라 같은 사이클 반복 주문 방지용.
|
||||
AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN: float = 15.0
|
||||
# [L-2 4회차 2026-10-02] 계좌 단위 거부 msg_cd — 종목이 아니라 계좌 문제라 다른 종목도
|
||||
# 같은 결과다. 받으면 그 사이클 신규 매수 중단 + 위 쿨다운 동안 전 종목 매수 보류(청산 무관).
|
||||
# 40910000 = 모의투자 계좌 교체/만료(메모리: kis-virtual-account-rotation).
|
||||
KIS_ACCOUNT_REJECT_MSG_CODES: list[str] = ["40910000"]
|
||||
ANALYSIS_STOCK_COUNT: int = 300 # 배치 분석 종목 수
|
||||
AUTO_TRADE_TOP_N_PER_RULE: int = 2 # 규칙당 자동매매 실행 최대 종목 수
|
||||
|
||||
@@ -538,6 +600,88 @@ class Settings(BaseSettings):
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 스윙 전략 플래그 (2026-10-02) — 사용자 결정("거래가 늘어나는 방향")으로 모멘텀·
|
||||
# 시간손절은 기본 ON. ⚠️ CLAUDE.md §8.1-5(백테스트 없이 실전 투입 금지) 미충족 상태.
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 모멘텀 돌파: 오늘(장중) 봉을 제외한 20일 고점/평균거래량으로 판정하는 _v2 사용.
|
||||
# False 면 기존 v1(오늘 봉 포함 → current_close > max(highs[-20:]) 이 구조적으로 불가,
|
||||
# 실전 발화 0)을 호출한다.
|
||||
MOMENTUM_BREAKOUT_ENABLED: bool = True
|
||||
# 스윙 시간손절(check_time_stop_exit) 배선. 기간·최소수익은 아래 두 키.
|
||||
SWING_TIME_STOP_ENABLED: bool = True
|
||||
SWING_TIME_STOP_DAYS: int = 20
|
||||
SWING_TIME_STOP_MIN_PROFIT_PCT: float = 3.0
|
||||
# [L-6 2026-10-02] 스윙 손절 파라미터 합리 범위(resolve_swing_stop_params_v2 의 소스 값 검증).
|
||||
# 범위 밖 값(strategy_cfg·적응형)은 warning 후 하위 소스(최종 config)로 폴백한다.
|
||||
# 손절률은 크기(abs, %) 기준, ATR 배수는 배수 그대로.
|
||||
SWING_STOP_LOSS_PCT_MIN: float = 1.0
|
||||
SWING_STOP_LOSS_PCT_MAX: float = 30.0
|
||||
SWING_ATR_STOP_MULT_MIN: float = 0.5
|
||||
SWING_ATR_STOP_MULT_MAX: float = 10.0
|
||||
# [M-2 2026-10-02 5회차] 결과 불명 스윙 매도 후 재매도 전 KIS 잔고 확인이 연속 이 횟수
|
||||
# 이상 실패하면 warning → error 로 승격(그동안 재매도는 보류).
|
||||
SWING_PENDING_SELL_CONFIRM_ERROR_AFTER: int = 3
|
||||
# [H-1/M-1 2026-10-02] 현재가 조회 실패 시 손절 판정용 대체 가격(직전 실시세·Portfolio 평가가)의
|
||||
# 최대 나이(초). 초과한 값은 쓰지 않는다 — 둘 다 초과면 손절 판정 skip + error(수동 확인).
|
||||
SWING_FALLBACK_PRICE_MAX_AGE_SEC: float = 900.0
|
||||
# [C-1 2026-10-02 6회차] 국내 스윙 매도 직전 KIS 잔고 확인이 종목별로 연속 이 횟수 이상 실패하면
|
||||
# warning → error 로 승격(그동안 매도는 보류 — DT 물량 매도 방지가 우선).
|
||||
SWING_PRESELL_HOLDING_CHECK_ERROR_AFTER: int = 3
|
||||
# [M-4 2026-10-02 6회차] 현재가·대체가 모두 없어 손절 판정을 연속 이 횟수 skip 한 종목은
|
||||
# 텔레그램 시스템 경고(종목별)를 보낸다.
|
||||
SWING_FALLBACK_SKIP_ALERT_AFTER: int = 5
|
||||
# [7회차 H-A] get_balance 첫 페이지 행 수(모의 20 / 실전 50). 종목 미발견 + 이 행 수 이상이면 페이지 초과로 보고 매도 보류.
|
||||
SWING_BALANCE_FULL_PAGE_ROWS: int = 20
|
||||
# ML 단독 SELL 트리거(신뢰도≥0.5). 운영 LSTM 재학습 전 난수(§8.5-20)이므로 OFF.
|
||||
ML_SELL_SIGNAL_ENABLED: bool = False
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 데이트레이딩 하드코딩 이관 (2026-10-02) — 값은 기존 리터럴과 동일(동작 무변경)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# ORB 갭 필터 상한(비율). 기본 판정은 |현재가−당일시가|/당일시가 (오버나이트 갭 아님).
|
||||
DAY_TRADE_ORB_MAX_GAP_PCT: float = 0.04
|
||||
# ORB 레인지 최소 폭(비율) — (고−저)/저 가 이 값 미만이면 노이즈 돌파로 보고 거부.
|
||||
DAY_TRADE_ORB_MIN_RANGE_PCT: float = 0.003
|
||||
# [기본 OFF] 갭 필터를 §7 문서대로 '전일 종가 대비 오버나이트 갭'으로 판정.
|
||||
# ON 이면 진입 경로가 워밍업(전일 포함) 1분봉을 ORB 판정에 넘기고, 전일 종가를 못 구하면
|
||||
# 기존 당일시가 판정으로 폴백(warning). 진입 감소 방향이라 사용자 결정으로 OFF 출고.
|
||||
DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED: bool = False
|
||||
# [기본 OFF] ORB 레인지를 09:00~09:29 전체로 확정. OFF(현행 실제 동작)는 첫 수집 성공
|
||||
# 시점(1분봉 ≥5개 → 대개 09:05 전후)의 레인지로 하루가 고정된다 — 돌파 신호가 더 많다.
|
||||
# ON: 09:30 이전엔 매 수집마다 재계산하고(Redis 저장분도 09:30 전엔 확정본으로 보지 않음),
|
||||
# 09:30 이후 수집분만 확정본으로 둔다.
|
||||
DAY_TRADE_ORB_FULL_RANGE: bool = False
|
||||
# 5분봉 RSI 과매수 청산 임계(기존 리터럴 70).
|
||||
DAY_TRADE_RSI_EXIT_OVERBOUGHT: float = 70.0
|
||||
# VWAP 반등 신호에서 ATR 이 없을 때 목표가 = VWAP × 이 값(기존 리터럴 1.03).
|
||||
DAY_TRADE_VWAP_TARGET_FALLBACK_MULT: float = 1.03
|
||||
# DT 청산 체크 처리량·상한 (exit_service 모듈 상수에서 이관, 값 동일).
|
||||
DAY_TRADE_EXIT_CHECK_CONCURRENCY: int = 4 # 포지션 동시 판정 수
|
||||
DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC: float = 15.0 # 손절 선판정 현재가·잔고 조회 상한
|
||||
DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC: float = 45.0 # 포지션별 분봉 판정 상한
|
||||
DAY_TRADE_TOTAL_ASSETS_REFRESH_SEC: float = 300.0 # 청산 측 총자산 갱신 주기
|
||||
# 전일(stale) 포지션 매도 실패 시 재시도 (당일·프로세스 단위).
|
||||
# MAX_RETRIES 는 '업무 거부'만 센다(레이트리밋·전송 전 실패·전송 불명은 미산정).
|
||||
DAY_TRADE_STALE_SELL_MAX_RETRIES: int = 3
|
||||
DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC: float = 60.0
|
||||
# 업무 거부가 MAX_RETRIES 에 도달한 뒤에도 당일 포기하지 않고 이 간격으로 계속 재시도.
|
||||
DAY_TRADE_STALE_SELL_EXHAUSTED_RETRY_SEC: float = 600.0
|
||||
# 청산 주문 1건 응답 대기 상한. 초과 = 결과 불명 → 당일 주문내역으로 접수 확인.
|
||||
# ⚠️ 이 상한에는 전송 전 대기(분산락·종목 세마포어·토큰)도 포함된다(전송 시점 표식은
|
||||
# trade_executor/korea_investment_api 쪽 훅이 필요 — 미구현).
|
||||
DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC: float = 60.0
|
||||
# 결과 불명이고 접수 확인도 안 될 때 같은 종목 재매도 보류 시간(재시도 직전에 다시 확인).
|
||||
# 2026-10-02: 120 → 30 — 해제 직전 재확인 + 실보유 상한 도입에 맞춰 단축(손절 지연 축소).
|
||||
DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC: float = 30.0
|
||||
# 결과 불명 주문을 당일 주문내역에서 찾을 때 주문 시각 허용 오차(초, 서버·KIS 시계 차).
|
||||
DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC: float = 60.0
|
||||
# 체결 후 반영 꼬리 단계(Redis 저장·loss_tracker·포트폴리오 캐시) 단계별 상한(초).
|
||||
DAY_TRADE_EXIT_FINALIZE_STEP_TIMEOUT_SEC: float = 10.0
|
||||
# 결과 불명 매도 이후 확인용 조회(KIS 잔고·비DT 보유 수량)가 연속 이 횟수만큼 실패하면
|
||||
# 손절이 보류된 상태이므로 텔레그램으로 수동 청산 경보를 보낸다(종목·일 1회).
|
||||
DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT: int = 3
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._apply_kis_mode()
|
||||
|
||||
53
backend/app/core/log_throttle.py
Normal file
53
backend/app/core/log_throttle.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""키별 레이트리밋 경고 로깅 헬퍼 — 순수 모듈(settings·DB·네트워크 참조 없음).
|
||||
|
||||
고빈도 루프(종목별 스캔·시세 캐시 미스 등)에서 반복되는 예외를 ``logger.debug`` 로
|
||||
숨기지 않으면서(CLAUDE.md §4: 예외 로그는 최소 warning) 로그 범람도 막기 위한 것이다.
|
||||
같은 ``key`` 는 ``interval`` 초에 1회만 warning 으로 기록하고, 그 사이 억제된 횟수는
|
||||
다음 출력에 ``(+N건 억제)`` 로 덧붙인다. 키는 정적 태그(종목코드 등 가변값 금지)여야
|
||||
메모리가 불어나지 않는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
DEFAULT_INTERVAL_SEC = 300.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
# key -> (마지막 출력 monotonic 시각, 그 이후 억제 횟수)
|
||||
_state: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def should_log(key: str, interval: float = DEFAULT_INTERVAL_SEC) -> tuple[bool, int]:
|
||||
"""(출력 여부, 직전 출력 이후 억제된 횟수). 출력하기로 하면 상태를 갱신한다."""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
last, suppressed = _state.get(key, (None, 0))
|
||||
if last is None or now - last >= interval:
|
||||
_state[key] = (now, 0)
|
||||
return True, suppressed
|
||||
_state[key] = (last, suppressed + 1)
|
||||
return False, suppressed + 1
|
||||
|
||||
|
||||
def warn_throttled(
|
||||
logger: logging.Logger,
|
||||
key: str,
|
||||
msg: str,
|
||||
*args: object,
|
||||
interval: float = DEFAULT_INTERVAL_SEC,
|
||||
) -> bool:
|
||||
"""같은 key 당 interval 초에 1회만 ``logger.warning(msg, *args)``. 출력했으면 True."""
|
||||
ok, suppressed = should_log(key, interval)
|
||||
if not ok:
|
||||
return False
|
||||
if suppressed:
|
||||
msg = f"{msg} (+{suppressed}건 억제, {interval:.0f}초 내 동일 키)"
|
||||
logger.warning(msg, *args, stacklevel=2)
|
||||
return True
|
||||
|
||||
|
||||
def reset_for_tests() -> None:
|
||||
with _lock:
|
||||
_state.clear()
|
||||
@@ -19,6 +19,8 @@ _MASK_PATTERNS = [
|
||||
(re.compile(r'(app_?key[":\s=]+)[A-Za-z0-9]{8,}', re.I), r'\1***'),
|
||||
# 계좌번호 (account_no / account_number)
|
||||
(re.compile(r'(account[_\s]?(?:no|number)[":\s=]+)\d{6,}', re.I), r'\1***'),
|
||||
# URL 쿼리의 token= (WebSocket 인증 토큰, M-6)
|
||||
(_QUERY_TOKEN_RE := re.compile(r'([?&](?:token|api_key|apikey)=)[^&\s"\']+', re.I), r'\1***'),
|
||||
]
|
||||
|
||||
|
||||
@@ -43,3 +45,39 @@ class KSTFormatter(logging.Formatter):
|
||||
"""포맷팅 후 민감 정보 마스킹 적용."""
|
||||
formatted = super().format(record)
|
||||
return _mask_sensitive(formatted)
|
||||
|
||||
|
||||
def _redact_query_token(value):
|
||||
if isinstance(value, str) and "=" in value:
|
||||
return _QUERY_TOKEN_RE.sub(r"\1***", value)
|
||||
return value
|
||||
|
||||
|
||||
class QueryTokenRedactionFilter(logging.Filter):
|
||||
"""레코드의 msg/args 안에 있는 `?token=...` 을 `***` 로 바꾼다 (M-6).
|
||||
|
||||
uvicorn 은 자체 포매터를 쓰므로 KSTFormatter 마스킹이 적용되지 않는다. uvicorn.access 의
|
||||
AccessFormatter 는 args 튜플 구조에 의존하므로 args 를 지우지 않고 원소만 치환한다.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
record.msg = _redact_query_token(record.msg)
|
||||
if isinstance(record.args, tuple):
|
||||
record.args = tuple(_redact_query_token(a) for a in record.args)
|
||||
elif isinstance(record.args, dict):
|
||||
record.args = {k: _redact_query_token(v) for k, v in record.args.items()}
|
||||
except Exception: # 로깅 필터가 로그를 막으면 안 된다
|
||||
pass # harness: allow-except-pass 로깅 필터 내부 — 여기서 로그를 남기면 재귀/로그 유실
|
||||
return True
|
||||
|
||||
|
||||
_REDACT_LOGGERS = ("uvicorn", "uvicorn.error", "uvicorn.access", "websockets", "websockets.server")
|
||||
|
||||
|
||||
def install_query_token_redaction() -> None:
|
||||
"""uvicorn/websockets 로거에 토큰 마스킹 필터를 1회 설치(중복 설치 방지)."""
|
||||
for name in _REDACT_LOGGERS:
|
||||
lg = logging.getLogger(name)
|
||||
if not any(isinstance(f, QueryTokenRedactionFilter) for f in lg.filters):
|
||||
lg.addFilter(QueryTokenRedactionFilter())
|
||||
|
||||
@@ -21,11 +21,90 @@ except ImportError:
|
||||
_kr_holidays = None
|
||||
|
||||
|
||||
def _krx_year_end_closure(year: int) -> date_type:
|
||||
"""KRX 연말 휴장일: 12/31, 단 그날이 주말·공휴일이면 직전 영업일."""
|
||||
d = date_type(year, 12, 31)
|
||||
while d.weekday() >= 5 or (_kr_holidays is not None and d in _kr_holidays):
|
||||
d -= timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
# [L-7 2026-10-02 4회차] 파싱 결과 캐시 — (원문 설정값, 파싱 결과). 원문이 바뀔 때만 다시
|
||||
# 파싱하므로 형식 오류 warning 도 설정값당 1회만 나간다(이전엔 is_krx_holiday 호출마다 —
|
||||
# 거래일 계산 루프에서는 하루당 1회씩 — 재파싱·재경고).
|
||||
_extra_holidays_cache: tuple[str, frozenset] | None = None
|
||||
|
||||
|
||||
def _extra_krx_holidays() -> set[date_type]:
|
||||
"""settings.KRX_EXTRA_HOLIDAYS (YYYY-MM-DD 콤마 구분) — 패키지에 없는 임시 휴장일."""
|
||||
global _extra_holidays_cache
|
||||
try:
|
||||
from app.core.config import settings
|
||||
raw = getattr(settings, "KRX_EXTRA_HOLIDAYS", "") or ""
|
||||
except Exception:
|
||||
return set()
|
||||
raw = str(raw)
|
||||
_c = _extra_holidays_cache
|
||||
if _c is not None and _c[0] == raw:
|
||||
return set(_c[1])
|
||||
out: set[date_type] = set()
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
out.add(date_type.fromisoformat(part))
|
||||
except ValueError:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("[market_schedule] KRX_EXTRA_HOLIDAYS 형식 오류 무시: %r", part)
|
||||
_extra_holidays_cache = (raw, frozenset(out))
|
||||
return out
|
||||
|
||||
|
||||
def is_krx_holiday(d: date_type) -> bool:
|
||||
"""KRX 휴장일(주말 제외) 여부.
|
||||
|
||||
- 법정 공휴일·대체공휴일·선거일: holidays.KR() (패키지 없으면 이 항목만 빠진다)
|
||||
- KRX 전용: 근로자의날(5/1), 연말 휴장(12/31 또는 직전 영업일)
|
||||
(holidays 0.93 은 financial_holidays("XKRX") 를 지원하지 않는다 — 2026-10-02 확인)
|
||||
- 운영 추가분: settings.KRX_EXTRA_HOLIDAYS
|
||||
"""
|
||||
if _kr_holidays is not None and d in _kr_holidays:
|
||||
return True
|
||||
if d.month == 5 and d.day == 1:
|
||||
return True
|
||||
if d.month == 12 and d == _krx_year_end_closure(d.year):
|
||||
return True
|
||||
return d in _extra_krx_holidays()
|
||||
|
||||
|
||||
def _is_holiday(d: date_type) -> bool:
|
||||
"""공휴일 여부 확인. holidays 패키지 없으면 False 반환."""
|
||||
if _kr_holidays is None:
|
||||
return False
|
||||
return d in _kr_holidays
|
||||
"""휴장일 여부 (KRX 기준). 내부 호출부 호환용 — `is_krx_holiday` 위임."""
|
||||
return is_krx_holiday(d)
|
||||
|
||||
|
||||
def count_krx_trading_days_between(start: date_type, end: date_type) -> int:
|
||||
"""`start` 다음 날부터 `end` 까지(양끝: start 제외·end 포함) KRX 거래일 수.
|
||||
|
||||
주말과 `is_krx_holiday()` 휴장일을 뺀다. `end <= start` 면 0.
|
||||
시간손절(스윙 보유 N거래일) 판정용 계산 헬퍼 — 네트워크·DB 참조 없음.
|
||||
(datetime 이 들어오면 날짜 부분만 쓴다.)
|
||||
"""
|
||||
if start is None or end is None:
|
||||
return 0
|
||||
if isinstance(start, datetime):
|
||||
start = start.date()
|
||||
if isinstance(end, datetime):
|
||||
end = end.date()
|
||||
if end <= start:
|
||||
return 0
|
||||
n = 0
|
||||
d = start + timedelta(days=1)
|
||||
while d <= end:
|
||||
if d.weekday() < 5 and not is_krx_holiday(d):
|
||||
n += 1
|
||||
d += timedelta(days=1)
|
||||
return n
|
||||
|
||||
|
||||
def is_market_open() -> bool:
|
||||
@@ -101,19 +180,38 @@ def is_pre_market_window(minutes_before: int = 30) -> bool:
|
||||
return (open_dt - timedelta(minutes=minutes_before)) <= now < open_dt
|
||||
|
||||
|
||||
def _dt_entry_cutoff() -> time:
|
||||
"""[L1 2026-10-02] DT 신규 진입 마감 — settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN (기본 15:00).
|
||||
|
||||
day_trading_strategy._get_entry_cutoff / entry_service 의 마감 판정과 같은 키다. 예전엔
|
||||
여기만 리터럴 15:00 이라 .env 로 마감을 바꾸면 진입 창과 스캔 마감이 어긋났다.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
return time(int(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR), int(settings.DAY_TRADE_ENTRY_CUTOFF_MIN))
|
||||
|
||||
|
||||
def _dt_eod_flat() -> time:
|
||||
"""[L1 2026-10-02] DT EOD 강제 청산 시작 — settings.DAY_TRADE_EOD_HOUR/MIN (기본 15:20).
|
||||
|
||||
day_trading_strategy._get_eod_flat 와 같은 키.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
return time(int(settings.DAY_TRADE_EOD_HOUR), int(settings.DAY_TRADE_EOD_MIN))
|
||||
|
||||
|
||||
def is_day_trade_entry_window() -> bool:
|
||||
"""데이트레이딩 신규 진입 가능 시간 (09:30~14:59)"""
|
||||
"""데이트레이딩 신규 진입 가능 시간 (09:30 ~ 진입 마감 직전, 기본 14:59)"""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
if _is_holiday(now.date()):
|
||||
return False
|
||||
t = now.time()
|
||||
return time(9, 30) <= t < time(15, 0)
|
||||
return time(9, 30) <= t < _dt_entry_cutoff()
|
||||
|
||||
|
||||
def is_eod_flat_time() -> bool:
|
||||
"""EOD 강제 청산 구간 (15:20~15:29)
|
||||
"""EOD 강제 청산 구간 (EOD 시작 ~ 15:29, 기본 15:20~15:29)
|
||||
|
||||
수정: 상한을 15:30 미만(<)으로 변경 — 15:30은 폐장 후.
|
||||
"""
|
||||
@@ -123,4 +221,4 @@ def is_eod_flat_time() -> bool:
|
||||
if _is_holiday(now.date()):
|
||||
return False
|
||||
t = now.time()
|
||||
return time(15, 20) <= t < time(15, 30)
|
||||
return _dt_eod_flat() <= t < time(15, 30)
|
||||
|
||||
@@ -42,10 +42,15 @@ async def init_redis() -> None:
|
||||
"""
|
||||
global _redis_client
|
||||
try:
|
||||
# [2026-10-02] 소켓 타임아웃 — 이전에는 무제한이라 Redis 가 행(응답 없음)이면 DT 청산
|
||||
# 마무리·분산락 획득이 무기한 멈췄다. 타임아웃 시 redis.TimeoutError 가 나고, 기존 호출부의
|
||||
# 예외 처리(캐시 skip·락 실패 경로)로 흘러간다.
|
||||
client = aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_timeout=settings.REDIS_SOCKET_TIMEOUT,
|
||||
socket_connect_timeout=settings.REDIS_SOCKET_CONNECT_TIMEOUT,
|
||||
)
|
||||
await client.ping()
|
||||
_redis_client = client
|
||||
|
||||
@@ -12,13 +12,26 @@
|
||||
|
||||
@router.get("/data")
|
||||
async def get_data(api_key: str | None = Depends(optional_api_key)): ...
|
||||
|
||||
[2026-10-02 검증 루프 2회차]
|
||||
- H-2: 세션 토큰은 서명/만료만이 아니라 **사용자 is_active** 까지 확인한다
|
||||
(`is_valid_credential_v2`). 이전에는 revoke 후에도 토큰 만료(24h)까지 통과했다.
|
||||
사용자 상태는 `SESSION_ACTIVE_CACHE_SECONDS` 동안 캐시한다.
|
||||
- H-2: 위험 쓰기 엔드포인트는 관리자만(`require_admin_for_listed_routes`). API 키(서버 간)는 관리자.
|
||||
- M-5: 세션 JWT 서명키를 SECRET_KEY 에서 파생한 별도 키로 분리했다. API_KEY 미설정 시
|
||||
API 키 = SECRET_KEY 이므로, 이전에는 API 키를 아는 쪽이 임의 사용자 세션을 위조할 수 있었다.
|
||||
- M-2: `client_ip_v2` — 신뢰 프록시에서 온 X-Real-IP 만 사용한다.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Security, status
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from jose import JWTError, jwt
|
||||
|
||||
@@ -30,6 +43,8 @@ logger = logging.getLogger(__name__)
|
||||
# 프론트 api 클라이언트·WebSocket 은 변경 없이 세션 토큰을 실어 보낼 수 있다.
|
||||
_SESSION_ALG = "HS256"
|
||||
_SESSION_AUD = "autostock-dashboard"
|
||||
# 세션 서명키 파생 컨텍스트 (M-5). 바꾸면 기존 세션이 전부 무효가 된다.
|
||||
_SESSION_KEY_CONTEXT = b"autostock-session-jwt-v2"
|
||||
|
||||
# API 키 헤더 스키마
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
@@ -45,30 +60,169 @@ def _get_api_key() -> str:
|
||||
return settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
|
||||
|
||||
|
||||
def _session_signing_key() -> str:
|
||||
"""세션 JWT 서명키 — SECRET_KEY 에서 HMAC 으로 파생 (M-5).
|
||||
|
||||
API_KEY 미설정 시 API 키가 SECRET_KEY 와 같아지므로, SECRET_KEY 로 직접 서명하면
|
||||
API 키 보유자(MCP 서버·스크립트)가 임의 사용자(관리자 포함) 세션을 위조할 수 있었다.
|
||||
"""
|
||||
return hmac.new(settings.SECRET_KEY.encode(), _SESSION_KEY_CONTEXT, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _session_verify_keys() -> list[str]:
|
||||
"""검증에 허용할 서명키 목록.
|
||||
|
||||
하위호환: 배포 전 발급된 세션(SECRET_KEY 직접 서명)은 **API_KEY 가 별도로 설정돼
|
||||
있을 때만** 받아준다 — 그 경우 API 키 보유자가 SECRET_KEY 를 모르므로 위조 위험이 없다.
|
||||
API_KEY 미설정 환경에서는 옛 토큰을 거부한다(배포 후 1회 재로그인).
|
||||
"""
|
||||
keys = [_session_signing_key()]
|
||||
if settings.API_KEY and settings.API_KEY != settings.SECRET_KEY:
|
||||
keys.append(settings.SECRET_KEY)
|
||||
return keys
|
||||
|
||||
|
||||
def create_session_token(username: str) -> tuple[str, datetime]:
|
||||
"""로그인 성공 시 발급하는 세션 토큰. (token, 만료시각 UTC) 반환."""
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.SESSION_TOKEN_EXPIRE_HOURS)
|
||||
payload = {"sub": username, "aud": _SESSION_AUD, "exp": expires_at, "iat": datetime.now(timezone.utc)}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=_SESSION_ALG), expires_at
|
||||
return jwt.encode(payload, _session_signing_key(), algorithm=_SESSION_ALG), expires_at
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> str | None:
|
||||
"""세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치)."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[_SESSION_ALG], audience=_SESSION_AUD)
|
||||
except JWTError:
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
return sub if isinstance(sub, str) and sub else None
|
||||
"""세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치).
|
||||
|
||||
⚠️ 서명·만료만 본다 — 사용자 활성 여부는 `is_valid_credential_v2` 가 확인한다.
|
||||
"""
|
||||
for key in _session_verify_keys():
|
||||
try:
|
||||
payload = jwt.decode(token, key, algorithms=[_SESSION_ALG], audience=_SESSION_AUD)
|
||||
except JWTError:
|
||||
continue
|
||||
sub = payload.get("sub")
|
||||
return sub if isinstance(sub, str) and sub else None
|
||||
return None
|
||||
|
||||
|
||||
def is_valid_credential(token: str) -> bool:
|
||||
"""X-API-Key 값이 API 키이거나 유효한 세션 토큰이면 True."""
|
||||
"""X-API-Key 값이 API 키이거나 서명이 유효한 세션 토큰이면 True.
|
||||
|
||||
⚠️ 동기 레거시 판정 — 사용자 is_active 를 보지 않는다. 인증 경로는
|
||||
`is_valid_credential_v2` 를 쓸 것(require_api_key/optional_api_key/WebSocket 은 전환됨).
|
||||
"""
|
||||
if _constant_time_compare(token, _get_api_key()):
|
||||
return True
|
||||
return verify_session_token(token) is not None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# H-2: 세션 사용자 상태(is_active/is_admin) 확인 — 짧은 TTL 캐시
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class SessionUserFlags:
|
||||
exists: bool
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
|
||||
|
||||
# username -> (flags, monotonic fetched_at)
|
||||
_session_user_cache: dict[str, tuple[SessionUserFlags, float]] = {}
|
||||
|
||||
|
||||
def invalidate_session_user_cache(username: str | None = None) -> None:
|
||||
"""승인/회수 직후 호출 — 캐시 TTL 을 기다리지 않고 즉시 반영한다."""
|
||||
if username is None:
|
||||
_session_user_cache.clear()
|
||||
else:
|
||||
_session_user_cache.pop(username, None)
|
||||
|
||||
|
||||
async def _fetch_session_user_flags_impl(username: str) -> SessionUserFlags:
|
||||
"""DB 에서 사용자 상태 조회 (SELECT 전용, commit 없음).
|
||||
|
||||
엔드포인트 의존성(HTTP 요청·WebSocket 핸드셰이크)에서만 호출된다 —
|
||||
실시간 매매 계층이 아니다(CLAUDE.md §3.1 대상 아님).
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import User
|
||||
|
||||
# DB-READ-EXCEPTION: 인증 의존성의 사용자 상태 SELECT 전용(commit 없음). HTTP 요청·WebSocket
|
||||
# 핸드셰이크 단계에서만, 캐시 미스(TTL SESSION_ACTIVE_CACHE_SECONDS)일 때만 실행 — 매매 실시간 계층 아님.
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(User.is_active, User.is_admin).where(User.username == username)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return SessionUserFlags(exists=False, is_active=False, is_admin=False)
|
||||
return SessionUserFlags(exists=True, is_active=bool(row[0]), is_admin=bool(row[1]))
|
||||
|
||||
|
||||
async def get_session_user_flags(username: str) -> SessionUserFlags:
|
||||
"""캐시 우선 사용자 상태 조회.
|
||||
|
||||
DB 조회 실패 시: 이전 캐시값이 있으면(만료됐어도) 그것을 쓰고, 없으면 503.
|
||||
401 을 돌려주면 프론트가 토큰을 지우고 로그아웃시키므로 일시 장애에는 503 이 맞다.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cached = _session_user_cache.get(username)
|
||||
ttl = max(0, int(settings.SESSION_ACTIVE_CACHE_SECONDS))
|
||||
if cached is not None and (now - cached[1]) < ttl:
|
||||
return cached[0]
|
||||
try:
|
||||
flags = await _fetch_session_user_flags_impl(username)
|
||||
except Exception as e:
|
||||
if cached is not None:
|
||||
logger.warning(
|
||||
"[Security] 사용자 상태 조회 실패 — 만료된 캐시값 사용(user=%s): %s", username, e
|
||||
)
|
||||
return cached[0]
|
||||
logger.error("[Security] 사용자 상태 조회 실패 — 인증 보류(user=%s): %s", username, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도하세요.",
|
||||
)
|
||||
_session_user_cache[username] = (flags, now)
|
||||
if len(_session_user_cache) > 1000:
|
||||
for k in [k for k, (_, t) in _session_user_cache.items() if (now - t) > ttl]:
|
||||
_session_user_cache.pop(k, None)
|
||||
return flags
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialInfo:
|
||||
kind: str # "api_key" | "session"
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
async def resolve_credential_v2(token: str | None) -> CredentialInfo | None:
|
||||
"""X-API-Key 값 해석. API 키 → 관리자. 세션 → 서명·만료 + **활성 사용자**만. 그 외 None."""
|
||||
if not token:
|
||||
return None
|
||||
if _constant_time_compare(token, _get_api_key()):
|
||||
return CredentialInfo(kind="api_key", username="api-key", is_admin=True)
|
||||
username = verify_session_token(token)
|
||||
if not username:
|
||||
return None
|
||||
flags = await get_session_user_flags(username)
|
||||
if not flags.exists or not flags.is_active:
|
||||
logger.warning(
|
||||
"[Security] %s 사용자 세션 토큰 거부: user=%s",
|
||||
"삭제된" if not flags.exists else "비활성(회수/미승인)", username,
|
||||
)
|
||||
return None
|
||||
return CredentialInfo(kind="session", username=username, is_admin=flags.is_admin)
|
||||
|
||||
|
||||
async def is_valid_credential_v2(token: str | None) -> bool:
|
||||
"""`is_valid_credential` 의 비동기 판 — 세션 사용자의 is_active 까지 확인 (H-2)."""
|
||||
return (await resolve_credential_v2(token)) is not None
|
||||
|
||||
|
||||
async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str:
|
||||
"""매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용.
|
||||
|
||||
@@ -81,8 +235,8 @@ async def require_api_key(api_key: str | None = Security(_api_key_header)) -> st
|
||||
detail="API 키가 필요합니다. X-API-Key 헤더를 설정하세요.",
|
||||
)
|
||||
|
||||
if not is_valid_credential(api_key):
|
||||
# 401: 자격증명 자체가 무효(잘못된 키·만료된 세션) — 프론트가 저장된 토큰을
|
||||
if not await is_valid_credential_v2(api_key):
|
||||
# 401: 자격증명 자체가 무효(잘못된 키·만료된 세션·회수된 계정) — 프론트가 저장된 토큰을
|
||||
# 지우고 로그인 화면으로 보내는 트리거. 403은 권한 부족(관리자 전용)에만 사용.
|
||||
# (세션 24h 만료 후 403이 돌아와 화면 전체가 오류로 멈추던 문제, 2026-09-22)
|
||||
logger.warning("[Security] API 키/세션 토큰 불일치 — 요청 거부")
|
||||
@@ -102,7 +256,7 @@ async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> s
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
if not is_valid_credential(api_key):
|
||||
if not await is_valid_credential_v2(api_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
|
||||
@@ -110,6 +264,151 @@ async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> s
|
||||
return api_key
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# H-2: 위험 쓰기 엔드포인트 — 관리자 전용
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (METHOD, /api/v1 이후 라우트 템플릿). 라우터 파일을 건드리지 않고 한 곳에서 관리한다.
|
||||
# 새 위험 엔드포인트를 만들면 여기에 추가할 것.
|
||||
ADMIN_ONLY_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
||||
("POST", "/risk/mdd/reset"),
|
||||
("PUT", "/strategy-config/{key}"),
|
||||
("PUT", "/strategy-config"),
|
||||
("POST", "/strategy-config/{key}/reset"),
|
||||
("POST", "/strategy-config/reset-all"),
|
||||
("PATCH", "/strategy-config/{key}/toggle"),
|
||||
("POST", "/daytrading/close-all"),
|
||||
("POST", "/daytrading/execute"),
|
||||
("POST", "/daytrading-v2/close-all"),
|
||||
("POST", "/playbook/circuit-breaker"),
|
||||
("DELETE", "/playbook/circuit-breaker"),
|
||||
("POST", "/mcp/tools/call"),
|
||||
})
|
||||
# 접두 일치 — 하위 전 경로·전 메서드가 관리자 전용
|
||||
ADMIN_ONLY_PREFIXES: tuple[str, ...] = ("/debug/",)
|
||||
_API_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
def is_admin_only_route(method: str, route_path: str) -> bool:
|
||||
"""라우트 템플릿(예: /api/v1/strategy-config/{key})이 관리자 전용인가."""
|
||||
path = route_path[len(_API_PREFIX):] if route_path.startswith(_API_PREFIX) else route_path
|
||||
if any(path.startswith(p) for p in ADMIN_ONLY_PREFIXES):
|
||||
return True
|
||||
return (method.upper(), path) in ADMIN_ONLY_ROUTES
|
||||
|
||||
|
||||
async def _assert_admin_impl(api_key: str | None) -> CredentialInfo:
|
||||
info = await resolve_credential_v2(api_key)
|
||||
if info is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
|
||||
)
|
||||
if not info.is_admin:
|
||||
logger.warning("[Security] 관리자 전용 엔드포인트 — 비관리자 거부: user=%s", info.username)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
|
||||
return info
|
||||
|
||||
|
||||
async def require_admin_v2(api_key: str | None = Security(_api_key_header)) -> str:
|
||||
"""관리자 전용 의존성 (DB 세션 의존 없음). API 키 또는 활성 관리자 세션만 통과."""
|
||||
info = await _assert_admin_impl(api_key)
|
||||
return info.username
|
||||
|
||||
|
||||
async def require_admin_for_listed_routes(
|
||||
request: Request, api_key: str | None = Security(_api_key_header)
|
||||
) -> None:
|
||||
"""api_router 전역 의존성 — `ADMIN_ONLY_ROUTES` 에 해당하는 요청만 관리자 확인.
|
||||
|
||||
그 외 라우트에는 아무것도 하지 않는다(각 라우터의 require/optional 정책이 그대로 적용).
|
||||
"""
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", None) or request.url.path
|
||||
if not is_admin_only_route(request.method, route_path):
|
||||
return
|
||||
await _assert_admin_impl(api_key)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M-2: 클라이언트 IP — 신뢰 프록시의 X-Real-IP 만 사용
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_trusted_cache: tuple[str, list] | None = None
|
||||
|
||||
|
||||
def _trusted_proxy_networks() -> list:
|
||||
global _trusted_cache
|
||||
raw = settings.TRUSTED_PROXY_CIDRS or ""
|
||||
if _trusted_cache is not None and _trusted_cache[0] == raw:
|
||||
return _trusted_cache[1]
|
||||
nets = []
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(part, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("[Security] TRUSTED_PROXY_CIDRS 항목 무시(형식 오류): %r", part)
|
||||
_trusted_cache = (raw, nets)
|
||||
return nets
|
||||
|
||||
|
||||
def _is_trusted_proxy(host: str | None) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(ip in net for net in _trusted_proxy_networks())
|
||||
|
||||
|
||||
def client_ip_v2(request) -> str:
|
||||
"""요청자 IP.
|
||||
|
||||
- 직전 홉(소켓 주소)이 `TRUSTED_PROXY_CIDRS` 안이면 nginx 가 덮어쓴 `X-Real-IP` 를 쓴다.
|
||||
- 그 외에는 소켓 주소. **X-Forwarded-For 는 보지 않는다** — 클라이언트가 임의로 넣을 수
|
||||
있어 로그인 잠금을 우회하는 데 쓰였다(첫 값 신뢰).
|
||||
"""
|
||||
peer = request.client.host if getattr(request, "client", None) else None
|
||||
if _is_trusted_proxy(peer):
|
||||
real = (request.headers.get("x-real-ip") or "").strip()
|
||||
if real:
|
||||
try:
|
||||
ipaddress.ip_address(real)
|
||||
return real
|
||||
except ValueError:
|
||||
logger.warning("[Security] X-Real-IP 형식 오류 무시: %r", real[:64])
|
||||
return peer or "unknown"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M-5: 키 강도 점검
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_WEAK_SECRETS = frozenset({
|
||||
"change-this-secret-key",
|
||||
"auto_stock_secret_key_change_in_production_2024",
|
||||
"secret",
|
||||
"your-secret-key-here",
|
||||
})
|
||||
|
||||
|
||||
def security_key_issues() -> list[str]:
|
||||
"""키 설정 문제 목록(값은 노출하지 않는다). 빈 리스트면 정상."""
|
||||
issues: list[str] = []
|
||||
min_len = int(settings.MIN_SECRET_KEY_LENGTH)
|
||||
sk = settings.SECRET_KEY or ""
|
||||
if sk.lower() in _WEAK_SECRETS:
|
||||
issues.append("SECRET_KEY 가 기본/예제 값이다")
|
||||
elif len(sk) < min_len:
|
||||
issues.append(f"SECRET_KEY 길이 {len(sk)} < {min_len}")
|
||||
if not settings.API_KEY:
|
||||
issues.append("API_KEY 미설정 — API 키가 SECRET_KEY 로 폴백된다(키 분리 권장)")
|
||||
elif len(settings.API_KEY) < min_len:
|
||||
issues.append(f"API_KEY 길이 {len(settings.API_KEY)} < {min_len}")
|
||||
return issues
|
||||
|
||||
|
||||
def generate_secure_key(length: int = 64) -> str:
|
||||
"""보안 키 생성 유틸리티 (운영 환경 시크릿 생성용)."""
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
Reference in New Issue
Block a user