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:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -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()

View 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()

View File

@@ -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())

View File

@@ -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)

View File

@@ -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

View File

@@ -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)