diff --git a/.env.example b/.env.example index 9c29194c..08634e2b 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ KIS_APP_KEY= KIS_APP_SECRET= KIS_ACCOUNT_NO= +# live 전략별 계좌는 settings.yaml의 kis_api.accounts에 키를 먼저 선언해야 합니다. +# 예: accounts: {scoring: ""} 선언 후 아래 값을 사용합니다. +# KIS_ACCOUNT_NO_SCORING= # --- (선택) KIS API 호출 제한 --- # MAX_CALLS_PER_SEC=10 diff --git a/.github/workflows/safety-regression.yml b/.github/workflows/safety-regression.yml index b0c68c26..9f7c7fd7 100644 --- a/.github/workflows/safety-regression.yml +++ b/.github/workflows/safety-regression.yml @@ -31,8 +31,8 @@ jobs: - name: Install test dependencies run: | python -m pip install --upgrade pip - python -m pip install -e . - python -m pip install pytest pytest-asyncio pandas numpy scipy sqlalchemy pyyaml loguru requests click matplotlib websockets + python -m pip install -r requirements.txt + python -m pip install -e . --no-deps - name: Compile safety modules run: | @@ -44,22 +44,7 @@ jobs: TMP: ${{ runner.temp }} TEMP: ${{ runner.temp }} run: | - python -m pytest \ - tests/test_order_executor_paper.py \ - tests/test_executor_state_machine.py \ - tests/test_paper_runtime.py \ - tests/test_paper_preflight.py \ - tests/test_paper_pilot.py \ - tests/test_audit_safety.py \ - tests/test_critical_fixes.py \ - tests/test_live_status_sync.py \ - tests/test_scheduler.py \ - tests/test_target_weight_rotation.py \ - tests/test_target_weight_paper_adapter.py \ - tests/test_live_gate.py \ - tests/test_paper_evidence.py \ - tests/test_promotion_engine.py \ - tests/test_evaluate_and_promote.py + python -m pytest tests -q - name: Run operator artifact checks run: | @@ -70,5 +55,5 @@ jobs: python tools/evaluate_and_promote.py --check-only python tools/evaluate_and_promote.py --current-blockers-check else - echo "operator artifacts not present in this checkout; skipping artifact sync checks" + echo "operator artifacts are runtime evidence and are not committed; sync checks require an artifact-enabled operational run" fi diff --git a/README.md b/README.md index 14570a20..e1e865fe 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,103 @@ -# QUANT TRADER +눈금 심볼 -![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white) -![Tests](https://img.shields.io/badge/tests-1%2C600%2B-2EA043) -![Market](https://img.shields.io/badge/Market-KR%20stocks-0F766E) -![Mode](https://img.shields.io/badge/mode-paper%20%EC%9A%B4%EC%98%81%20%EC%A4%91-7C3AED) +# 눈금 NUNGUM -한국 주식 자동매매 개인 프로젝트. 데이터 수집부터 백테스트, 모의투자(paper), 리스크 관리, -일일 자동 운영, 웹 대시보드, KIS API 연동까지 한 저장소에서 돌린다. +**오래 투자하기 위한 기준과 기록.** -처음엔 단타 알파를 찾는 게 목표였다. 몇 달 동안 후보군을 바꿔가며 체계적으로 돌려봤고, -결론은 "내 규모에서 시장 예측으로 초과수익은 안 나온다"였다. 그 실패 기록은 지우지 않고 -[연구 로그](docs/RESEARCH_LOG.md)에 전부 남겨뒀다. 지금은 방향을 바꿔서 예측 없이 먹을 수 -있는 것만 조합해 굴리는 중이다. 지수 ETF 분산 + 유휴 현금은 CD금리 파킹 + 거래비용 최소화 -+ 월 적립. 대신 실전 주문 경로는 검증을 통과하기 전까지 전부 막아뒀다(fail-closed). +오래 투자하려면 오늘 무엇을 해야 하고, 무엇을 그냥 두어야 하는지부터 분명해야 합니다. -> 학습용 프로젝트고 투자 조언이 아님. +눈금은 한국 주식 포트폴리오를 모의 운용하며 자산 흐름, 적립 내역, 시스템 상태를 한 화면에서 살펴보는 로컬 대시보드입니다. 매일 시세를 쫓기보다 정한 기준을 지키고 기록을 쌓는 데 초점을 맞췄습니다. -## 운영 화면 +> 기본 설정은 모의 운용입니다. 실제 주문은 필요한 안전 조건을 확인하고 사용자가 직접 활성화하기 전에는 실행되지 않습니다. 수익과 원금은 보장하지 않습니다. -![운영 대시보드](docs/images/dashboard-main.png) +![오늘의 운용 판단과 자산 요약](docs/images/dashboard-overview.png) -운영 대시보드 (2026-07-10 캡처). 트랙별 평가금, 수익률(TWR), MDD, 주식 배치율, 보유 종목을 -한 화면에서 본다. 월 적립은 우측 상단 버튼으로 기록하는데, 입금은 시간가중수익률로 중화돼서 -수익률이 왜곡되지 않는다. 대시보드에서 할 수 있는 쓰기 작업은 입금 기록 하나뿐이고 -매매나 설정 변경은 못 한다. +## 화면 둘러보기 -```bash -python main.py --mode dashboard # http://127.0.0.1:8080 -``` +### 오늘 볼 일만 먼저 -## 지금 굴리는 트랙 (2026-07-10 기준) +적립 여부, 오래된 데이터, 거래 중지처럼 지금 살펴봐야 할 항목 하나를 첫 화면에 보여줍니다. 별일이 없으면 `오늘은 할 일이 없습니다`라고 알려줍니다. -| 트랙 | 구성 | 자본 | 진행 | -|------|------|------|------| -| `kr_pocket` 소액 적립 | KODEX 200 47.5% + CD금리 파킹 ETF 47.5% + 현금 5% | 30만 시작, 월 10만 적립 | paper 1/60일 | -| `kr_diversified_hold` | 대형주 10종목 균등 buy&hold, 저회전 | 1,000만 (paper) | 23/60일, 관찰용 | -| 단타 샌드박스 | paper 전용. 실돈은 60일 게이트 통과 후 따로 결정 | - | 대기 | +### 포트폴리오를 한눈에 -실제로 돈이 들어갈 트랙은 `kr_pocket` 하나다. +![포트폴리오별 자산과 투자 비중](docs/images/dashboard-portfolio.png) -- KODEX 200 1주면 그 자체로 200종목 분산이라 소액에서 유일하게 말이 되는 분산 수단 -- 나머지 절반을 그냥 현금으로 두면 이자 0이라, CD금리 누적형 ETF에 파킹 (연 3%대, 가격 변동 사실상 없음) -- 국내 상장 ETF는 매도 거래세 면제. 이걸 체결 비용 모델에도 반영해서 페이퍼 성적이 가짜 비용으로 깎이지 않게 함 -- 위험자산을 총자산의 절반으로 고정. 백테스트 기준 MDD가 주식 100% 대비 절반 수준 +주력 포트폴리오와 관찰용 포트폴리오를 나눠 보여줍니다. 현재 자산, 누적 원금, 입출금 제외 수익률(TWR), 최대 낙폭(MDD), 현금과 보유 종목을 한곳에서 볼 수 있습니다. -## 시스템 구성 +### 짧은 등락보다 긴 흐름 -```mermaid -flowchart LR - FDR["FinanceDataReader
시세·지수·수정주가"] --> RB - KIS["KIS API
REST·WebSocket"] --> EX - RB["바스켓 리밸런서
drift 트리거 · 회전 상한
1일 1매매 가드"] --> RG - RG["리스크 가드
MDD·일손실 한도
유동성·중복주문 차단"] --> EX - EX["주문 실행기
paper 체결 모델
live는 gate 통과 시에만"] --> DB[("SQLite WAL
거래·포지션·스냅샷·입금")] - DB --> OBS["관측성
헬스체크 · 디스코드
일일/주간 리포트"] - DB --> WEB["웹 대시보드
TWR · 입금 기록"] -``` +![기간별 자산 흐름과 모의 운용 기록](docs/images/dashboard-performance.png) -## paper → 실전 승격 +포트폴리오와 기간을 바꿔 자산 흐름을 살펴볼 수 있습니다. 운용 기록이 아직 짧다면 성과를 서둘러 판단하지 않도록 기록 일수와 누락 여부도 함께 표시합니다. -```mermaid -flowchart LR - BT["백테스트
비용·슬리피지·look-ahead 가드"] --> PP["paper 운영
60영업일 트랙레코드"] - PP -->|"스냅샷 커버리지 95% 이상
dead-letter 0건
비용 드래그 연 1% 이하"| PC["PASS_CANDIDATE"] - PC --> LG{"live gate"} - LG -->|"KIS 연결·잔고 동기화
blockers 통과 + 운영자 확인"| LIVE["실계좌 소액"] - LG -->|미충족| PP -``` +### 문제가 생겼을 때만 자세히 -paper 트랙레코드가 기준을 못 넘으면 실계좌는 안 열린다. 예전에 있던 `--force-live` 같은 -우회 플래그는 지웠다. +![거래 안전 상태와 오늘의 자동 운용 기록](docs/images/dashboard-operations.png) -## 일일 운영 +평소에는 거래 안전 상태, 시장 환경, 자동 운용과 데이터 시각만 간단히 보여줍니다. 필요할 때만 오늘의 처리 기록과 고급 진단을 펼쳐볼 수 있고, 거래가 멈추면 원인과 복구 순서를 따로 안내합니다. -평일 오전 10시에 한 사이클이 자동으로 돈다. +### 넣은 돈은 수익과 따로 -``` -리밸런싱 판단 → (필요 시) 주문 → NAV 스냅샷 → DB 백업 → 승격 진행률 → 디스코드 카드 -``` +![적립금 기록 전 최종 확인](docs/images/dashboard-deposit.png) -- 당일 스냅샷이 빠지면 critical 경보 (영업일 판정은 KST 기준) -- 금요일엔 백업 복구 리허설까지 돈다. 백업 파일이 실제로 복구되는지 매주 확인하는 용도 -- 평소 점검은 이거 하나로 끝: +적립금은 포트폴리오와 금액, 장부 모드를 마지막에 한 번 더 확인한 뒤 기록합니다. 입금으로 늘어난 금액이 투자 수익처럼 보이지 않도록 운용 성과와 분리해 계산합니다. -```bash -python main.py --mode health -# 종료코드 0=OK / 1=ATTENTION / 2=BLOCKED -# 승격 대기 같은 '원래 그런 상태'는 라벨로만 찍히고 경보로 안 올라온다 -``` +## 실행하기 -## 시작하기 +Python 3.11 또는 3.12가 필요합니다. ```bash -python -m venv .venv -.venv\Scripts\activate -pip install -r requirements.txt +git clone https://github.com/easygap/quant_trader.git +cd quant_trader -# config/settings.yaml.example → settings.yaml 복사, .env.example 참고해서 .env 작성 -# 디스코드 알림 쓰려면 .env에 DISCORD_WEBHOOK_URL 필요 +python -m venv .venv +# Windows PowerShell +.venv\Scripts\Activate.ps1 +# macOS/Linux: source .venv/bin/activate -python main.py --mode guide # 실행 모드 목록 -python main.py --mode backtest --strategy scoring --symbol 005930 -python main.py --mode rebalance --dry-run # 리밸런싱 계획만 확인 -python main.py --mode health # 운영 점검 -python main.py --mode weekly_report # 주간 요약 -pytest tests/ -q +pip install -r requirements.txt +cp config/settings.yaml.example config/settings.yaml +cp .env.example .env +python main.py --mode dashboard ``` -월 적립 기록은 대시보드 버튼이 편하고, CLI도 있다. +기본 바인드는 http://127.0.0.1:8080입니다. 브라우저에서 이 주소를 열면 됩니다. 대시보드는 인증 없이 금융 정보를 다루므로 현재 PC에서만 접속할 수 있습니다. + +처음 시작해 화면에 운용 기록이 없다면 모의 운용을 한 번 실행하세요. ```bash -python tools/record_deposit.py --basket kr_pocket --amount 100000 +python main.py --mode paper ``` -## 안전장치 +KIS 모의투자나 알림을 사용할 때만 `.env`에 필요한 값을 채웁니다. `.env`와 실제 계좌 정보는 Git에 올리지 마세요. -한 번씩 데인 뒤에 추가된 것들이라 목록이 길다. 기본 방침은 이렇다. +## 처음이라면 이렇게 보세요 -- 데이터 조회 실패, 상태 불명, 검증 불가면 주문 안 한다 (fail-closed) -- 거래·포지션·현금이 어긋난 반쪽 원장을 남기지 않는다. 포지션 저장이 실패하면 방금 쓴 매매 기록을 되돌리고 실패를 위로 올린다 -- 파이프라인상 원래 그런 상태는 라벨, 진짜 장애만 경보. 매일 울리는 경보는 결국 아무도 안 본다 +1. 화면 위쪽에서 오늘 살펴볼 일을 봅니다. +2. 주력 포트폴리오의 자산과 현금 비중을 살펴봅니다. +3. 장기 성과에서 자산 흐름이 어떻게 이어지고 있는지 봅니다. +4. 실제로 넣은 금액이 있다면 **적립금 기록**에 남깁니다. +5. 모의 운용 기록이 충분히 쌓이기 전에는 실전 주문을 사용하지 않습니다. -
-세부 목록 펼치기 +## 실제 주문 전에 -- 포트폴리오 MDD·일손실 한도 도달 시 신규 매수 차단 (손절·청산 SELL은 유지) -- 미체결/중복 주문 방지. live 미체결 조회가 실패하면 "미체결 있음"으로 간주 -- 신규 매수 직전 유동성(평균 거래량·거래대금) 재검증, 누락 시 차단 -- 갭 리스크·상관관계·업종 비중 확인용 데이터 조회 실패 시 신규 매수 차단 -- 주문/청산 판단 가격이 0, NaN, 누락이면 판단 보류 + 차단 이벤트 기록 -- 시장 국면 필터 데이터 불명 시 unknown 국면으로 신규 매수 차단 -- live 체결 확인 전 DB 반영 보류, 주문번호 불일치 시에도 보류 -- live 시작 전 KIS 연결·잔고 동기화 실패 시 스케줄러 시작 차단 -- 주문 예외성 실패는 ORDER_ERROR critical 이벤트 + 디스코드 즉시 알림 -- 1일 1매매 가드. 사이클이 중복 실행돼도 회전 상한을 우회하지 못함 -- 스키마 마이그레이션은 멱등, 중단 지점부터 재개 가능, 행수 검증 실패 시 원본 보존 -- DB 백업 보존 14일 + 매주 금요일 복구 리허설 -- 대시보드 쓰기는 입금 기록 하나뿐, CSRF 방어 적용 -- 긴급 청산은 POST 전용, 127.0.0.1 바인드, 토큰 검증 기본 적용 +처음 내려받은 설정은 아래 상태입니다. -
+```yaml +kis_api: + use_mock: true +trading: + mode: "paper" + auto_entry: false +``` -파라미터는 `config/risk_params.yaml`, `config/baskets.yaml`에서 관리. +가격, 잔고나 체결 상태가 불확실하면 새 주문을 차단합니다. 부분 체결이나 장부 저장 실패처럼 직접 점검이 필요한 상황에서는 거래를 멈추고 화면에 이유를 남깁니다. 어떤 안전장치도 시장 급변, 슬리피지, API 장애나 투자 손실을 완전히 없앨 수는 없습니다. -## 폴더 구조 +## 더 자세히 -``` -quant_trader/ -├── main.py # 모드 라우터 (backtest/rebalance/health/dashboard/...) -├── config/ # 설정 (baskets, risk_params, strategies, settings) -├── core/ # 리밸런서, 리스크, 주문 실행, 헬스, 관측성, paper 런타임 -├── strategies/ # 전략들 (현재 전부 연구 보관 상태) -├── backtest/ # 백테스터 (비용·슬리피지·이벤트 가드 반영) -├── database/ # SQLite 모델, 리포지토리, 마이그레이션, 백업 -├── monitoring/ # 로깅, 디스코드, 웹 대시보드 -├── api/ # KIS REST·WebSocket -├── tools/ # 운영 도구 (입금 기록, 평가, 트랙 재시작, 시뮬레이터) -├── scripts/ # 검증 스크립트 -├── deploy/ # (선택) Oracle Cloud ARM 상시 구동 -├── tests/ # 외부 API는 모킹, DB는 격리 -└── docs/ # 문서, 스크린샷 -``` +- [사용·운영 가이드](docs/PROJECT_GUIDE.md) +- [안전 장치와 복구 순서](docs/SAFETY_MODEL.md) +- [소액 적립 포트폴리오 설계](docs/POCKET_TRACK_PLAN.md) +- [연구 결과와 한계](docs/PROFITABILITY_FINDINGS.md) -## 문서 - -| 문서 | 내용 | -|------|------| -| [PROFITABILITY_FINDINGS](docs/PROFITABILITY_FINDINGS.md) | 수익성 점검 결론. 뭘 시도했고 왜 접었는지 | -| [POCKET_TRACK_PLAN](docs/POCKET_TRACK_PLAN.md) | 소액 적립 트랙 설계. 기대치, 구성, 입금, 게이트 | -| [BASKET_PAPER_EVALUATION](docs/BASKET_PAPER_EVALUATION.md) | paper→실전 승격 기준과 자동 판정 | -| [BASKET_LIVE_RUNBOOK](docs/BASKET_LIVE_RUNBOOK.md) | 실전 전환 절차 (모의서버 리허설 → 소액 → 목표 자본) | -| [PROJECT_GUIDE](docs/PROJECT_GUIDE.md) | 파일 역할, 모드별 흐름, 실전 전 체크리스트 | -| [RESEARCH_LOG](docs/RESEARCH_LOG.md) | 연구·운영 이력 아카이브. 알파 탐색 실패 기록 포함 | -| [quant_trader_design](quant_trader_design.md) | 아키텍처, 전략, 리스크 설계 | +이 프로젝트는 개인 연구와 모의 운용을 위한 도구이며 투자 조언이 아닙니다. 실제 자금을 사용하기 전에는 코드와 설정, 증권사 규정, 세금 조건을 직접 검토하세요. diff --git a/api/kis_api.py b/api/kis_api.py index df13c2f8..3140c64b 100644 --- a/api/kis_api.py +++ b/api/kis_api.py @@ -8,8 +8,11 @@ import time import json +import math import random import ssl +import contextvars +from contextlib import contextmanager from collections import deque from datetime import datetime, timedelta from typing import Optional, Dict, Any, List @@ -22,6 +25,59 @@ from api.circuit_breaker import get_breaker +_RATE_STATE_REGISTRY_LOCK = threading.Lock() +_RATE_STATE_REGISTRY: dict[tuple[str, str], dict[str, Any]] = {} +_ORDER_SUBMISSION_AUTHORIZED = contextvars.ContextVar( + "kis_order_submission_authorized", + default=False, +) + + +@contextmanager +def authorized_kis_order_submission(): + """OrderExecutor가 모든 가드를 통과한 짧은 구간에만 실주문을 허용한다.""" + token = _ORDER_SUBMISSION_AUTHORIZED.set(True) + try: + yield + finally: + _ORDER_SUBMISSION_AUTHORIZED.reset(token) + + +def _shared_rate_state( + base_url: str, + app_key: str, + max_calls_per_sec: float, + max_calls_per_min: int, +) -> dict[str, Any]: + """같은 KIS app/domain 인스턴스들이 하나의 호출 예산을 공유한다.""" + key = (str(base_url), str(app_key)) + with _RATE_STATE_REGISTRY_LOCK: + state = _RATE_STATE_REGISTRY.get(key) + if state is None: + state = { + "max_calls_per_sec": max_calls_per_sec, + "max_calls_per_min": max_calls_per_min, + "tokens": max_calls_per_sec, + "last_refill": time.monotonic(), + "minute_window": deque(), + "token_lock": threading.Lock(), + "minute_lock": threading.Lock(), + } + _RATE_STATE_REGISTRY[key] = state + else: + # 인스턴스별 설정이 다르면 더 보수적인 한도를 프로세스 전체에 적용한다. + state["max_calls_per_sec"] = min( + float(state["max_calls_per_sec"]), max_calls_per_sec + ) + state["max_calls_per_min"] = min( + int(state["max_calls_per_min"]), max_calls_per_min + ) + state["tokens"] = min( + float(state["tokens"]), float(state["max_calls_per_sec"]) + ) + return state + + class KISTokenExpiredError(Exception): """KIS API 401 응답(토큰 만료) 시 사용. CircuitBreaker 실패로 누적하지 않음.""" @@ -63,6 +119,12 @@ def __init__(self, account_no: str = None): self.base_url = kis.get("mock_url", "https://openapivts.koreainvestment.com:29443") else: self.base_url = kis.get("base_url", "https://openapi.koreainvestment.com:9443") + if self.use_mock is True and not self._is_confirmed_mock_endpoint(): + logger.critical( + "KIS use_mock=true이지만 실효 URL이 공식 VTS가 아님 — " + "실돈 가능 endpoint로 보호합니다: {}", + self.base_url, + ) # 인증 토큰 self._access_token = None @@ -78,14 +140,20 @@ def __init__(self, account_no: str = None): # 2) Sliding Window: 분당 한도 (지속적 버스트 방지) self.max_calls_per_sec = float(kis.get("max_calls_per_sec", 10.0)) self.max_calls_per_min = int(kis.get("max_calls_per_min", 300)) - self._tokens = self.max_calls_per_sec - self._last_refill = time.monotonic() - self._token_lock = threading.Lock() + self._rate_state = _shared_rate_state( + self.base_url, + self.app_key, + self.max_calls_per_sec, + self.max_calls_per_min, + ) + self._tokens = self._rate_state["tokens"] + self._last_refill = self._rate_state["last_refill"] + self._token_lock = self._rate_state["token_lock"] self._auth_lock = threading.Lock() # 분당 슬라이딩 윈도우: 최근 60초 내 요청 타임스탬프 - self._minute_window: deque[float] = deque() - self._minute_lock = threading.Lock() + self._minute_window = self._rate_state["minute_window"] + self._minute_lock = self._rate_state["minute_lock"] # 모니터링 카운터 (사용량 추적) self._total_requests = 0 @@ -96,10 +164,19 @@ def __init__(self, account_no: str = None): # 토큰 에러 쿨다운: 발급 실패 시 60초간 재시도 억제 self._token_error_until: float = 0.0 + masked_account = "미설정" + if self.account_no: + digits = self.cano + masked_account = ( + f"{'*' * max(0, len(digits) - 2)}{digits[-2:]}-**" + if digits + else "****-**" + ) + logger.info( "KIS API 초기화 완료 (모드: {}, 계좌: {}, RateLimit: {}/sec, {}/min)", - "모의투자" if self.use_mock else "실전", - self.account_no, + "모의투자(VTS)" if self._is_confirmed_mock_endpoint() else "실전/미확인", + masked_account, self.max_calls_per_sec, self.max_calls_per_min, ) @@ -130,6 +207,18 @@ def _is_configured(self) -> bool: and len(self.app_key) > 0 ) + def _is_confirmed_mock_endpoint(self) -> bool: + """플래그와 실효 URL이 모두 KIS VTS일 때만 모의 주문으로 신뢰한다.""" + return ( + getattr(self, "use_mock", None) is True + and "openapivts.koreainvestment.com" + in str(getattr(self, "base_url", "")).strip().lower() + ) + + def _requires_order_capability(self) -> bool: + """실전 또는 정체를 확신할 수 없는 endpoint는 실주문처럼 보호한다.""" + return not self._is_confirmed_mock_endpoint() + @staticmethod def _mask_key(key: str, head: int = 4, tail: int = 4) -> str: """민감 정보 마스킹 (앞뒤 일부만 노출). 빈 문자열은 그대로 반환.""" @@ -232,54 +321,63 @@ def _wait_for_token(self): self._wait_for_minute_window() # 2) 초당 Token Bucket - with self._token_lock: + state = self._rate_state + with state["token_lock"]: while True: now = time.monotonic() - elapsed = now - self._last_refill + elapsed = now - float(state["last_refill"]) + max_per_sec = float(state["max_calls_per_sec"]) - self._tokens = self._tokens + (elapsed * self.max_calls_per_sec) - if self._tokens > self.max_calls_per_sec: - self._tokens = self.max_calls_per_sec - self._last_refill = now + state["tokens"] = float(state["tokens"]) + (elapsed * max_per_sec) + if state["tokens"] > max_per_sec: + state["tokens"] = max_per_sec + state["last_refill"] = now - if self._tokens >= 1.0: - self._tokens = self._tokens - 1.0 + if state["tokens"] >= 1.0: + state["tokens"] = state["tokens"] - 1.0 break else: - sleep_time = (1.0 - self._tokens) / self.max_calls_per_sec + sleep_time = (1.0 - state["tokens"]) / max_per_sec time.sleep(max(0.01, sleep_time)) + self._tokens = state["tokens"] + self._last_refill = state["last_refill"] # 분당 윈도우에 현재 요청 기록 - with self._minute_lock: - self._minute_window.append(time.monotonic()) + with state["minute_lock"]: + state["minute_window"].append(time.monotonic()) self._total_requests += 1 def _wait_for_minute_window(self): """분당 한도 초과 시 가장 오래된 요청이 윈도우를 벗어날 때까지 대기.""" while True: - with self._minute_lock: + state = self._rate_state + with state["minute_lock"]: now = time.monotonic() cutoff = now - 60.0 - while self._minute_window and self._minute_window[0] < cutoff: - self._minute_window.popleft() - if len(self._minute_window) < self.max_calls_per_min: + window = state["minute_window"] + max_per_min = int(state["max_calls_per_min"]) + while window and window[0] < cutoff: + window.popleft() + if len(window) < max_per_min: return - oldest = self._minute_window[0] + oldest = window[0] wait = oldest - cutoff + 0.1 logger.debug( "분당 한도 도달 ({}/{}), {:.1f}초 대기", - len(self._minute_window), self.max_calls_per_min, wait, + len(window), max_per_min, wait, ) time.sleep(wait) def get_rate_limit_stats(self) -> dict: """현재 Rate Limiter 사용량 통계 반환.""" - with self._minute_lock: + state = self._rate_state + with state["minute_lock"]: now = time.monotonic() cutoff = now - 60.0 - while self._minute_window and self._minute_window[0] < cutoff: - self._minute_window.popleft() - recent_minute = len(self._minute_window) + window = state["minute_window"] + while window and window[0] < cutoff: + window.popleft() + recent_minute = len(window) elapsed_sec = max(1, time.monotonic() - self._session_start) return { @@ -287,10 +385,12 @@ def get_rate_limit_stats(self) -> dict: "total_429s": self._total_429s, "total_conn_errors": self._total_conn_errors, "requests_last_60s": recent_minute, - "max_per_sec": self.max_calls_per_sec, - "max_per_min": self.max_calls_per_min, + "max_per_sec": state["max_calls_per_sec"], + "max_per_min": state["max_calls_per_min"], "avg_per_sec": round(self._total_requests / elapsed_sec, 2), - "minute_utilization_pct": round(recent_minute / self.max_calls_per_min * 100, 1), + "minute_utilization_pct": round( + recent_minute / int(state["max_calls_per_min"]) * 100, 1 + ), "token_cooldown_active": time.monotonic() < self._token_error_until, } @@ -358,6 +458,16 @@ def _request( if response.status_code == 429: self._total_429s += 1 + if not idempotent: + # 주문 POST는 브로커가 요청을 처리했는지 클라이언트가 + # 단정할 수 없는 응답을 받으면 절대 재전송하지 않는다. + # 상위 OrderExecutor가 주문을 UNKNOWN으로 유지하고 + # 미체결/체결 조회를 통해 reconcile하도록 맡긴다. + logger.error( + "비멱등 요청 HTTP 429 — 재전송하지 않고 체결 여부 불명 처리: {}", + path, + ) + raise KISOrderResponseUnknown(f"{path}: HTTP 429") try: retry_after = int(response.headers.get("Retry-After", 5)) except (TypeError, ValueError): @@ -373,6 +483,18 @@ def _request( if response.status_code in (500, 502, 503, 504): breaker.on_failure() + if not idempotent: + # 5xx는 브로커가 주문을 접수한 뒤 응답 생성에 실패한 + # 경우를 배제할 수 없다. 같은 주문을 다시 POST하면 + # 이중 체결될 수 있으므로 UNKNOWN/reconcile로 전환한다. + logger.error( + "비멱등 요청 HTTP {} — 재전송하지 않고 체결 여부 불명 처리: {}", + response.status_code, + path, + ) + raise KISOrderResponseUnknown( + f"{path}: HTTP {response.status_code}" + ) wait = self._backoff_with_jitter(attempt) logger.warning( "[{}] 서버 오류, {:.1f}초 후 재시도 ({}/{}) - 경로: {}", @@ -654,6 +776,14 @@ def buy_order( Returns: 주문 결과 딕셔너리 """ + if self._requires_order_capability() and not _ORDER_SUBMISSION_AUTHORIZED.get(): + logger.critical( + "OrderExecutor capability 없는 직접 실계좌 BUY 호출 차단: {}", + symbol, + ) + raise PermissionError( + "실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다." + ) # 모의투자 vs 실전 거래 ID tr_id = "VTTC0802U" if self.use_mock else "TTTC0802U" @@ -701,6 +831,14 @@ def sell_order( Returns: 주문 결과 딕셔너리 """ + if self._requires_order_capability() and not _ORDER_SUBMISSION_AUTHORIZED.get(): + logger.critical( + "OrderExecutor capability 없는 직접 실계좌 SELL 호출 차단: {}", + symbol, + ) + raise PermissionError( + "실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다." + ) tr_id = "VTTC0801U" if self.use_mock else "TTTC0801U" body = { @@ -728,6 +866,88 @@ def sell_order( logger.error("매도 주문 실패: {} - {}", symbol, msg) return None + def cancel_order( + self, + order_no: str, + order_branch: str, + quantity: int, + order_type: str, + exchange_id: str, + ) -> Optional[Dict[str, Any]]: + """국내주식 미체결 주문의 지정 수량을 취소한다. + + KIS 공식 ``주식주문(정정취소)`` 규격을 따른다. 호출자는 먼저 + :meth:`get_cancelable_order_status`로 ``psbl_qty``를 확인해야 한다. + 취소 역시 비멱등 요청이므로 응답 유실 시 재전송하지 않고 + :class:`KISOrderResponseUnknown`을 전파한다. + """ + if self._requires_order_capability() and not _ORDER_SUBMISSION_AUTHORIZED.get(): + logger.critical( + "OrderExecutor capability 없는 직접 실계좌 CANCEL 호출 차단: {}", + order_no, + ) + raise PermissionError( + "실계좌 주문 취소는 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다." + ) + + normalized_order_no = str(order_no or "").strip() + normalized_branch = str(order_branch or "").strip() + normalized_order_type = str(order_type or "").strip() + normalized_exchange = str(exchange_id or "").strip().upper() + try: + raw_quantity = float(quantity) + normalized_quantity = int(raw_quantity) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("취소 수량은 1 이상의 정수여야 합니다.") from exc + if not normalized_order_no: + raise ValueError("원주문번호가 필요합니다.") + if not normalized_branch: + raise ValueError("주문채번지점번호가 필요합니다.") + if ( + not math.isfinite(raw_quantity) + or raw_quantity != normalized_quantity + or normalized_quantity <= 0 + ): + raise ValueError("취소 수량은 1 이상이어야 합니다.") + if not normalized_order_type: + raise ValueError("원주문 주문구분코드가 필요합니다.") + if normalized_exchange not in {"KRX", "NXT", "SOR"}: + raise ValueError("거래소ID구분코드는 KRX, NXT, SOR 중 하나여야 합니다.") + + tr_id = "VTTC0013U" if self.use_mock else "TTTC0013U" + body = { + "CANO": self.cano, + "ACNT_PRDT_CD": self.acnt_prdt_cd, + "KRX_FWDG_ORD_ORGNO": normalized_branch, + "ORGN_ODNO": normalized_order_no, + "ORD_DVSN": normalized_order_type, + "RVSE_CNCL_DVSN_CD": "02", # 취소 + # 조회 직후에도 일부 체결될 수 있으므로, 조회된 가능수량만 명시적으로 취소한다. + "ORD_QTY": str(normalized_quantity), + "ORD_UNPR": "0", + "QTY_ALL_ORD_YN": "N", + "EXCG_ID_DVSN_CD": normalized_exchange, + } + data = self._request( + "POST", + "/uapi/domestic-stock/v1/trading/order-rvsecncl", + tr_id, + body=body, + idempotent=False, + ) + if data and data.get("rt_cd") == "0": + logger.warning( + "미체결 주문 취소 접수: order_no={} qty={} exchange={}", + normalized_order_no, + normalized_quantity, + normalized_exchange, + ) + return data.get("output", {}) + + msg = data.get("msg1", "알 수 없는 오류") if data else "API 응답 없음" + logger.error("미체결 주문 취소 실패: {} - {}", normalized_order_no, msg) + return None + @staticmethod def _odno_from_order_output(order_output: Optional[Dict[str, Any]]) -> str: if not order_output or not isinstance(order_output, dict): @@ -997,6 +1217,216 @@ def _remaining_qty_from_order_row(row: Dict[str, Any]) -> int: return qty return 0 + @staticmethod + def _nonnegative_int_from_order_row( + row: Dict[str, Any], + *keys: str, + ) -> Optional[int]: + """주문 응답의 정수 필드를 보수적으로 읽는다. + + 필드가 없거나 숫자가 아니면 ``None``을 반환해 호출자가 상태 불명으로 + 처리하게 한다. 음수는 유효한 수량이 아니므로 역시 ``None``이다. + """ + for key in keys: + value = row.get(key) + if value is None or value == "": + continue + try: + number = float(value) + parsed = int(number) + except (TypeError, ValueError, OverflowError): + continue + if not math.isfinite(number) or number != parsed or parsed < 0: + continue + return parsed + return None + + def get_cancelable_order_status( + self, + symbol: str, + side: str = "BUY", + ) -> Dict[str, Any]: + """정정·취소 가능한 국내주식 주문을 조회한다. + + KIS 공식 규격은 취소 전 ``psbl_qty`` 확인을 요구한다. 이 메서드는 + 종목과 매매 방향을 다시 필터링하고, 취소에 필요한 주문번호·지점번호· + 주문구분·거래소를 보존한다. 응답이 잘렸을 가능성이 있거나 필수 수량을 + 해석하지 못하면 ``checked=False``로 반환해 주문 경로를 fail-closed한다. + """ + target_symbol = str(symbol or "").strip() + normalized_side = str(side or "").strip().upper() + side_code = {"SELL": "1", "BUY": "2"}.get(normalized_side) + broker_side_code = {"SELL": "01", "BUY": "02"}.get(normalized_side) + if not target_symbol: + return { + "checked": False, + "reason": "cancelable_symbol_missing", + "orders": [], + } + if not side_code or not broker_side_code: + return { + "checked": False, + "reason": "cancelable_side_invalid", + "orders": [], + } + if not self._is_configured() or not self.cano: + return { + "checked": False, + "reason": "kis_not_configured", + "orders": [], + } + + try: + params = { + "CANO": self.cano, + "ACNT_PRDT_CD": self.acnt_prdt_cd, + "INQR_DVSN_1": "1", # 종목 기준 + "INQR_DVSN_2": side_code, + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + } + # 공식 최신 샘플은 실전/모의 모두 이 조회 TR을 사용한다. + data = self._request( + "GET", + "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl", + "TTTC0084R", + params=params, + ) + if not data or data.get("rt_cd") != "0": + return { + "checked": False, + "reason": "kis_cancelable_query_failed", + "message": ( + data.get("msg1") if isinstance(data, dict) else "API 응답 없음" + ), + "orders": [], + } + + output = data.get("output") or data.get("output1") or [] + if isinstance(output, dict): + output = [output] if output else [] + if not isinstance(output, list): + return { + "checked": False, + "reason": "kis_cancelable_output_invalid", + "orders": [], + } + + # 한 번에 최대 50건인 API다. 헤더의 연속조회 상태를 공통 래퍼가 + # 노출하지 않으므로 50건 경계에서는 목록이 완전하다고 추측하지 않는다. + if len(output) >= 50: + return { + "checked": False, + "reason": "kis_cancelable_pagination_required", + "orders": [], + "returned_count": len(output), + } + + orders: list[dict[str, Any]] = [] + malformed: list[dict[str, Any]] = [] + for item in output: + if not isinstance(item, dict): + continue + row_symbol = self._symbol_from_order_row(item) + if row_symbol != target_symbol: + continue + row_side = str( + item.get("sll_buy_dvsn_cd") + or item.get("SLL_BUY_DVSN_CD") + or broker_side_code + ).strip() + if row_side != broker_side_code: + continue + + cancelable_qty = self._nonnegative_int_from_order_row( + item, "psbl_qty", "PSBL_QTY" + ) + filled_qty = self._nonnegative_int_from_order_row( + item, "tot_ccld_qty", "TOT_CCLD_QTY" + ) + ordered_qty = self._nonnegative_int_from_order_row( + item, "ord_qty", "ORD_QTY" + ) + if cancelable_qty is None: + malformed.append({"symbol": row_symbol, "reason": "psbl_qty_invalid"}) + continue + if cancelable_qty <= 0: + continue + if filled_qty is None: + malformed.append({ + "symbol": row_symbol, + "reason": "tot_ccld_qty_invalid", + }) + continue + + order = { + "symbol": row_symbol, + "side": normalized_side, + "order_no": str( + item.get("odno") or item.get("ODNO") or "" + ).strip(), + "order_branch": str( + item.get("ord_gno_brno") + or item.get("ORD_GNO_BRNO") + or "" + ).strip(), + "cancelable_qty": cancelable_qty, + "filled_qty": filled_qty, + "ordered_qty": ordered_qty, + "order_price": item.get("ord_unpr") or item.get("ORD_UNPR") or "", + "order_type": str( + item.get("ord_dvsn_cd") + or item.get("ORD_DVSN_CD") + or "" + ).strip(), + "exchange_id": str( + item.get("excg_id_dvsn_cd") + or item.get("EXCG_ID_DVSN_CD") + or "" + ).strip().upper(), + "order_time": item.get("ord_tmd") or item.get("ORD_TMD") or "", + } + missing = [ + key + for key in ("order_no", "order_branch", "order_type", "exchange_id") + if not order[key] + ] + if missing: + malformed.append({ + "symbol": row_symbol, + "order_no": order["order_no"], + "reason": "cancel_fields_missing", + "missing": missing, + }) + continue + orders.append(order) + + if malformed: + return { + "checked": False, + "reason": "kis_cancelable_order_malformed", + "orders": orders, + "malformed": malformed, + } + return { + "checked": True, + "reason": "ok", + "has_cancelable": bool(orders), + "orders": orders, + } + except Exception as exc: + logger.warning( + "정정취소 가능 주문 조회 실패 — 긴급 매도 전 자동 취소 불가: {} — {}", + target_symbol, + exc, + ) + return { + "checked": False, + "reason": "kis_cancelable_query_exception", + "error": str(exc), + "orders": [], + } + def get_unfilled_order_status(self, symbol: str) -> Dict[str, Any]: """ 해당 종목 미체결 주문 조회 상태를 반환한다. @@ -1242,25 +1672,51 @@ def get_balance(self) -> Optional[Dict[str, Any]]: output1 = data.get("output1", []) # 종목별 보유 현황 output2 = data.get("output2", [{}]) # 계좌 요약 - positions = [] - for item in output1: - positions.append({ - "symbol": item.get("pdno", ""), - "name": item.get("prdt_name", ""), - "quantity": int(item.get("hldg_qty", 0)), - "avg_price": float(item.get("pchs_avg_pric", 0)), - "current_price": float(item.get("prpr", 0)), - "pnl_rate": float(item.get("evlu_pfls_rt", 0)), - "pnl_amount": float(item.get("evlu_pfls_amt", 0)), - }) + def _finite_number(value, *, minimum: float | None = None) -> float: + number = float(value or 0) + if not math.isfinite(number) or (minimum is not None and number < minimum): + raise ValueError(f"invalid numeric balance value: {value!r}") + return number - summary = output2[0] if output2 else {} - return { - "cash": float(summary.get("dnca_tot_amt", 0)), # 예수금 총액 - "total_value": float(summary.get("tot_evlu_amt", 0)), # 총 평가금액 - "total_pnl": float(summary.get("evlu_pfls_smtl_amt", 0)), # 총 평가손익 - "positions": positions, - } + try: + positions = [] + for item in output1: + quantity_value = _finite_number(item.get("hldg_qty", 0), minimum=0) + quantity = int(quantity_value) + if quantity_value != quantity: + raise ValueError( + f"fractional domestic holding quantity: {quantity_value!r}" + ) + positions.append({ + "symbol": item.get("pdno", ""), + "name": item.get("prdt_name", ""), + "quantity": quantity, + "avg_price": _finite_number( + item.get("pchs_avg_pric", 0), minimum=0 + ), + "current_price": _finite_number(item.get("prpr", 0), minimum=0), + "pnl_rate": _finite_number(item.get("evlu_pfls_rt", 0)), + "pnl_amount": _finite_number(item.get("evlu_pfls_amt", 0)), + }) + + summary = output2[0] if output2 else {} + parsed = { + "cash": _finite_number(summary.get("dnca_tot_amt", 0), minimum=0), + "total_value": _finite_number( + summary.get("tot_evlu_amt", 0), minimum=0 + ), + "total_pnl": _finite_number( + summary.get("evlu_pfls_smtl_amt", 0) + ), + "positions": positions, + } + except (TypeError, ValueError, OverflowError) as exc: + # NaN/Inf는 비교 연산에서 손실 한도를 우회할 수 있다. 잔고 전체를 + # 사용할 수 없는 것으로 처리해 상위 live BUY 경계가 fail-closed한다. + logger.error("KIS 잔고 숫자 검증 실패 — 응답 사용 거부: {}", exc) + return None + + return parsed def get_approval_key(self) -> str: """ @@ -1419,6 +1875,14 @@ def place_overseas_order( price: 지정가 (USD). 시장가 대체 시 0 → API 스펙상 \"0\" 문자열 전달 market: NAS / NYS / AMS """ + if self._requires_order_capability() and not _ORDER_SUBMISSION_AUTHORIZED.get(): + logger.critical( + "OrderExecutor capability 없는 직접 해외 실계좌 주문 차단: {}", + symbol, + ) + raise PermissionError( + "실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다." + ) _, ovrs = self.map_us_market_to_kis_codes(market) symb = str(symbol).strip().upper() sd = str(side).strip().lower() diff --git a/backtest/backtester.py b/backtest/backtester.py index 0fca80a2..3cb9e991 100644 --- a/backtest/backtester.py +++ b/backtest/backtester.py @@ -23,6 +23,22 @@ ) _PARTIAL_EXIT_ACTION = "TAKE_PROFIT_PARTIAL" +EXECUTION_MODEL_NEXT_OPEN = "next_open" +EXECUTION_MODEL_LEGACY_SAME_CLOSE = "legacy_same_close" +_SUPPORTED_EXECUTION_MODELS = frozenset( + (EXECUTION_MODEL_NEXT_OPEN, EXECUTION_MODEL_LEGACY_SAME_CLOSE) +) + + +def _validate_execution_model(execution_model: str) -> str: + """전략 신호 체결 모델을 검증한다.""" + if execution_model not in _SUPPORTED_EXECUTION_MODELS: + supported = ", ".join(sorted(_SUPPORTED_EXECUTION_MODELS)) + raise ValueError( + f"지원하지 않는 execution_model={execution_model!r}. 지원값: {supported}" + ) + return execution_model + def _count_roundtrips(trades: list) -> int: """완전 청산 1회당 왕복 1회 (부분 익절 후 잔량 청산까지 한 사이클로 묶음).""" @@ -103,6 +119,8 @@ def run( strict_lookahead: bool = True, param_overrides: dict = None, notify_overtrading: bool = False, + symbol: str = None, + execution_model: str = EXECUTION_MODEL_NEXT_OPEN, ) -> dict: """ 백테스팅 실행 @@ -114,6 +132,11 @@ def run( strict_lookahead: True면 매 시점 T에서 df[:T+1]만으로 지표/신호 계산 (Look-Ahead Bias 완전 방어, 느림) param_overrides: 전략 파라미터 덮어쓰기 (최적화 시 사용). 예: {"scoring": {"buy_threshold": 3, "sell_threshold": -3}} notify_overtrading: True면 과매매 경고 1건 이상일 때 Notifier(디스코드 등) 전송. 대량 백테스트는 False 권장. + symbol: 대상 종목코드. 종목별 거래세 면제·보유기간 과세에 사용. + None이면 기존 동작(일괄 주식 거래세)을 유지한다. + execution_model: 전략 신호 체결 모델. 기본값 ``next_open``은 T일 종가로 + 확정된 BUY/SELL 신호를 다음 거래일 시가에 체결한다. + 과거 결과 재현이 필요할 때만 ``legacy_same_close``를 사용한다. Returns: 백테스팅 결과 딕셔너리 @@ -123,6 +146,8 @@ def run( "position_sizing", {} ).get("initial_capital", 10000000) + execution_model = _validate_execution_model(execution_model) + self._param_overrides = param_overrides strategy = self._get_strategy(strategy_name) if strict_lookahead: @@ -171,8 +196,15 @@ def run( # 시장국면 시리즈 사전 계산 (TICKET-02) regime_series = self._precompute_regime_series(df_analyzed) - # 시뮬레이션 실행 (시점 T에서는 row T만 사용, T+1 이후 미참조) - result = self._simulate(df_analyzed, initial_capital, regime_series=regime_series) + # 전략 신호는 기본적으로 T 종가 확정 후 T+1 시가에 체결한다. + # 손절·갭·블랙스완 등 위험 청산은 _simulate 내부의 기존 즉시 규칙을 유지한다. + result = self._simulate( + df_analyzed, + initial_capital, + regime_series=regime_series, + symbol=symbol, + execution_model=execution_model, + ) result["look_ahead_bias_verified"] = ( "STRICT" if strict_lookahead else "DISABLED_WITH_WARNING" ) @@ -201,6 +233,7 @@ def run( "strategy": strategy_name, "period": f"{df.index[0]} ~ {df.index[-1]}", "initial_capital": initial_capital, + "execution_model": execution_model, "look_ahead_bias_verified": result.get("look_ahead_bias_verified", "PASS"), "overtrading_warnings": warn_list, } @@ -331,12 +364,21 @@ def _precompute_regime_series(self, df: pd.DataFrame) -> pd.Series: ) return result - def _simulate(self, df: pd.DataFrame, initial_capital: float, regime_series: pd.Series = None) -> dict: + def _simulate( + self, + df: pd.DataFrame, + initial_capital: float, + regime_series: pd.Series = None, + symbol: str = None, + execution_model: str = EXECUTION_MODEL_NEXT_OPEN, + ) -> dict: """ 거래 시뮬레이션 실행. - 방어: 날짜 순으로 순회하며 당일(row T) 데이터만 사용 — T+1 이후 행 미참조로 Look-Ahead Bias 없음. + 방어: 날짜 순으로 순회하며 미래 행을 참조하지 않는다. 기본 ``next_open``은 + T일 종가로 확정된 전략 신호를 T+1일 시가에 체결한다. 설정에 따라 ATR 손절, 1% 룰 포지션 사이징, 부분 익절을 반영한다. """ + execution_model = _validate_execution_model(execution_model) assert df.index.is_monotonic_increasing or len(df) <= 1, ( "시뮬레이션은 시간 순서대로만 순회해야 하며, 미래 데이터를 참조하지 않습니다." ) @@ -437,6 +479,8 @@ def _slippage_cost_vs_close(execution_price: float, ref_close: float, qty: int) blackswan_triggers = 0 blackswan_buy_blocks = 0 blackswan_recovery_buys = 0 + strategy_orders_skipped_missing_open = 0 + skipped_strategy_orders: list[dict] = [] def _is_near_backtest_earnings(row: pd.Series, date) -> bool: for flag_col in ("is_near_earnings", "near_earnings"): @@ -492,6 +536,7 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float "SELL", avg_daily_volume=row_volume, avg_price=avg_price, + symbol=symbol, ) sell_price = costs["execution_price"] sell_amount = sell_price * position @@ -516,19 +561,309 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float sold_today = True return True + def _execute_strategy_signal( + *, + signal: str, + signal_date, + strategy_execution_price: float | None, + strategy_row_atr, + strategy_row_volume, + regime_at_t: str, + date, + row: pd.Series, + current_idx: int, + previous_close: float | None, + ) -> None: + """확정된 전략 신호 하나를 해당 모델의 체결 시점에 처리한다.""" + nonlocal cash, position, avg_price, partial_exit_done, high_water_mark + nonlocal buy_date, sold_today, regime_buy_blocks, regime_caution_buys + nonlocal gap_up_buy_blocks, earnings_buy_blocks, blackswan_buy_blocks + nonlocal blackswan_recovery_buys, strategy_orders_skipped_missing_open + + action = str(signal or "HOLD").upper() + actionable = (action == "BUY" and position == 0 and not sold_today) or ( + action == "SELL" and position > 0 + ) + if actionable and strategy_execution_price is None: + strategy_orders_skipped_missing_open += 1 + skipped_strategy_orders.append( + { + "date": date, + "signal_date": signal_date, + "action": action, + "reason": "missing_or_invalid_open", + } + ) + return + + if position > 0 and min_holding_days > 0 and buy_date is not None: + holding_days_now = (date - buy_date).days + if holding_days_now < min_holding_days and action == "SELL": + action = "HOLD" + + if action == "BUY" and max_monthly_trades > 0: + try: + month_key = date.strftime("%Y-%m") + except Exception: + month_key = str(date)[:7] + if monthly_trade_counts.get(month_key, 0) >= max_monthly_trades: + action = "HOLD" + + if action == "BUY" and regime_at_t == "bearish": + action = "HOLD" + regime_buy_blocks += 1 + + if ( + action == "BUY" + and gap_enabled + and gap_up_entry_block > 0 + and previous_close is not None + and previous_close > 0 + ): + # 갭은 체결 모델과 무관하게 당일 시가/전일 종가로 측정한다. + # legacy_same_close의 체결가(close)를 쓰면 장중 되돌림이 있었던 + # 갭 상승일을 정상 진입으로 오판해 백테스트 손실 위험을 숨긴다. + raw_gap_open = row.get("open") + valid_gap_open = ( + raw_gap_open is not None + and pd.notna(raw_gap_open) + and float(raw_gap_open) > 0 + ) + if not valid_gap_open: + action = "HOLD" + strategy_orders_skipped_missing_open += 1 + skipped_strategy_orders.append( + { + "date": date, + "signal_date": signal_date, + "action": "BUY", + "reason": "missing_or_invalid_open_for_gap_guard", + } + ) + elif (float(raw_gap_open) - previous_close) / previous_close >= gap_up_entry_block: + action = "HOLD" + gap_up_buy_blocks += 1 + + if action == "BUY" and skip_earnings_days > 0 and _is_near_backtest_earnings(row, date): + action = "HOLD" + earnings_buy_blocks += 1 + + if action == "BUY" and bs_enabled and current_idx <= bs_cooldown_until_idx: + action = "HOLD" + blackswan_buy_blocks += 1 + + if action == "BUY" and position == 0 and not sold_today: + costs = self.risk_manager.calculate_transaction_costs( + strategy_execution_price, + 1, + "BUY", + avg_daily_volume=strategy_row_volume, + symbol=symbol, + ) + buy_price = costs["execution_price"] + stop_at_buy = buy_price * (1 - sl_rate) + if ( + sl_type == "atr" + and strategy_row_atr is not None + and pd.notna(strategy_row_atr) + and strategy_row_atr > 0 + ): + stop_at_buy = buy_price - float(strategy_row_atr) * atr_mult + risk_per_share = max(buy_price - stop_at_buy, buy_price * 0.001) + mark_price = ( + strategy_execution_price + if execution_model == EXECUTION_MODEL_NEXT_OPEN + else float(row["close"]) + ) + total_equity = cash + (position * mark_price) + risk_amount = total_equity * max_risk_per_trade + qty_by_1pct = int(risk_amount / risk_per_share) if risk_per_share > 0 else 0 + invest_cap = total_equity * max_position_ratio + qty_by_cap = int(invest_cap / buy_price) if buy_price > 0 else 0 + max_invest_value = total_equity * max_investment_ratio + qty_by_total_cap = int(max_invest_value / buy_price) if buy_price > 0 else 0 + quantity = min( + max(0, qty_by_1pct), + max(0, qty_by_cap), + max(0, qty_by_total_cap), + ) + + if quantity > 0 and regime_at_t == "caution": + quantity = max(1, int(quantity * caution_scale)) + regime_caution_buys += 1 + + if ( + quantity > 0 + and bs_enabled + and current_idx > bs_cooldown_until_idx + and current_idx <= bs_recovery_until_idx + and bs_recovery_scale < 1.0 + ): + quantity = max(1, int(quantity * bs_recovery_scale)) + blackswan_recovery_buys += 1 + + if ( + quantity > 0 + and bt_max_participation > 0 + and strategy_row_volume is not None + and strategy_row_volume > 0 + ): + max_qty_by_volume = int(strategy_row_volume * bt_max_participation) + if max_qty_by_volume <= 0: + quantity = 0 + elif quantity > max_qty_by_volume: + quantity = max_qty_by_volume + + if quantity > 0: + buy_costs = self.risk_manager.calculate_transaction_costs( + strategy_execution_price, + quantity, + "BUY", + avg_daily_volume=strategy_row_volume, + symbol=symbol, + ) + buy_price = buy_costs["execution_price"] + buy_amount = buy_price * quantity + commission = buy_costs["commission"] + else: + buy_amount = 0 + commission = 0 + + if quantity > 0 and (buy_amount + commission) <= cash: + cash -= buy_amount + commission + position = quantity + avg_price = buy_price + partial_exit_done = False + high_water_mark = buy_price + buy_date = date + trades.append( + { + "date": date, + "action": "BUY", + "price": buy_price, + "quantity": quantity, + "pnl": 0, + "pnl_rate": 0, + "commission": commission, + "tax": 0.0, + "slippage_cost": _slippage_cost_vs_close( + buy_price, strategy_execution_price, quantity + ), + "signal_date": signal_date, + } + ) + if max_monthly_trades > 0: + try: + month_key = date.strftime("%Y-%m") + except Exception: + month_key = str(date)[:7] + monthly_trade_counts[month_key] = monthly_trade_counts.get(month_key, 0) + 1 + + elif action == "SELL" and position > 0: + costs = self.risk_manager.calculate_transaction_costs( + strategy_execution_price, + position, + "SELL", + avg_daily_volume=strategy_row_volume, + avg_price=avg_price, + symbol=symbol, + ) + sell_price = costs["execution_price"] + sell_amount = sell_price * position + commission = costs["commission"] + tax = costs["tax"] + costs.get("capital_gains_tax", 0) + pnl = (sell_price - avg_price) * position - commission - tax + cash += sell_amount - commission - tax + trades.append( + { + "date": date, + "action": "SELL", + "price": sell_price, + "quantity": position, + "pnl": pnl, + "pnl_rate": ((sell_price / avg_price) - 1) * 100, + "commission": commission, + "tax": float(tax), + "slippage_cost": _slippage_cost_vs_close( + sell_price, strategy_execution_price, position + ), + "signal_date": signal_date, + } + ) + position = 0 + avg_price = 0 + partial_exit_done = False + high_water_mark = 0.0 + buy_date = None + sold_today = True + for i, (date, row) in enumerate(df.iterrows()): close = row["close"] - signal = row.get("signal", "HOLD") - open_price = row.get("open", close) - if pd.isna(open_price) or float(open_price) <= 0: - open_price = close + raw_open = row.get("open") + valid_open = ( + raw_open is not None + and pd.notna(raw_open) + and float(raw_open) > 0 + ) + if valid_open: + open_price = float(raw_open) + elif execution_model == EXECUTION_MODEL_LEGACY_SAME_CLOSE: + open_price = float(close) + else: + open_price = None + + # 전략 신호만 1봉 지연한다. 위험 청산은 아래에서 당일 가격을 사용해 + # 기존처럼 즉시 처리되며, 그 뒤 남은 포지션에만 예약 전략 주문을 적용한다. + if execution_model == EXECUTION_MODEL_NEXT_OPEN: + if i > 0: + strategy_row = df.iloc[i - 1] + signal = strategy_row.get("signal", "HOLD") + signal_date = df.index[i - 1] + else: + strategy_row = None + signal = "HOLD" + signal_date = None + strategy_execution_price = open_price + else: + strategy_row = row + signal = row.get("signal", "HOLD") + signal_date = date + strategy_execution_price = float(close) + row_atr = row.get("atr") avg_daily_vol = row.get("_avg_daily_volume") if pd.isna(avg_daily_vol) or avg_daily_vol <= 0: avg_daily_vol = row.get("volume") row_volume = avg_daily_vol + strategy_row_atr = strategy_row.get("atr") if strategy_row is not None else None + strategy_row_volume = ( + strategy_row.get("_avg_daily_volume") if strategy_row is not None else None + ) + if strategy_row_volume is None or pd.isna(strategy_row_volume) or strategy_row_volume <= 0: + strategy_row_volume = strategy_row.get("volume") if strategy_row is not None else None sold_today = False previous_close = float(df["close"].iloc[i - 1]) if i > 0 else None + regime_at_t = "bullish" + if regime_enabled and date in regime_series.index: + regime_at_t = regime_series.loc[date] + + # next_open은 당일 시가 전략 주문이 먼저, 이후 갭/블랙스완/종가 위험 + # 청산이 발생하는 실제 시간 순서를 따른다. 시가 결측 주문은 종가로 + # 대체하지 않고 명시적으로 스킵한다. + if execution_model == EXECUTION_MODEL_NEXT_OPEN: + _execute_strategy_signal( + signal=signal, + signal_date=signal_date, + strategy_execution_price=strategy_execution_price, + strategy_row_atr=strategy_row_atr, + strategy_row_volume=strategy_row_volume, + regime_at_t=regime_at_t, + date=date, + row=row, + current_idx=i, + previous_close=previous_close, + ) + stock_daily_return = None if previous_close is not None and previous_close > 0: stock_daily_return = (float(close) - previous_close) / previous_close @@ -538,12 +873,12 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float bs_daily_returns = bs_daily_returns[-bs_consecutive_days:] if position > 0 and previous_close is not None and previous_close > 0: - if gap_enabled: - gap_pct = (float(open_price) - previous_close) / previous_close + if gap_enabled and open_price is not None: + gap_pct = (open_price - previous_close) / previous_close if gap_pct <= gap_down_threshold: if _execute_full_exit( "GAP_DOWN", - float(open_price), + open_price, float(close), f"gap_down {gap_pct * 100:.2f}%", ): @@ -578,7 +913,8 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float # 최대 보유 기간 초과 시 강제 청산 if max_holding_days > 0 and holding_days >= max_holding_days: costs = self.risk_manager.calculate_transaction_costs( - close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price + close, position, "SELL", avg_daily_volume=row_volume, + avg_price=avg_price, symbol=symbol, ) sell_price = costs["execution_price"] sell_amount = sell_price * position @@ -604,7 +940,8 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float # 손절 elif close <= stop_loss_price: costs = self.risk_manager.calculate_transaction_costs( - close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price + close, position, "SELL", avg_daily_volume=row_volume, + avg_price=avg_price, symbol=symbol, ) sell_price = costs["execution_price"] sell_amount = sell_price * position @@ -631,7 +968,8 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float elif partial_exit and not partial_exit_done and close >= avg_price * (1 + partial_target): sell_qty = max(1, int(position * partial_ratio)) costs = self.risk_manager.calculate_transaction_costs( - close, sell_qty, "SELL", avg_daily_volume=row_volume, avg_price=avg_price + close, sell_qty, "SELL", avg_daily_volume=row_volume, + avg_price=avg_price, symbol=symbol, ) sell_price = costs["execution_price"] sell_amount = sell_price * sell_qty @@ -659,7 +997,8 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float # 전량 익절 elif close >= take_profit_price: costs = self.risk_manager.calculate_transaction_costs( - close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price + close, position, "SELL", avg_daily_volume=row_volume, + avg_price=avg_price, symbol=symbol, ) sell_price = costs["execution_price"] sell_amount = sell_price * position @@ -687,7 +1026,8 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float trail_price = _trailing_stop_price(high_water_mark, row_atr) if trail_price is not None and close <= trail_price: costs = self.risk_manager.calculate_transaction_costs( - close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price + close, position, "SELL", avg_daily_volume=row_volume, + avg_price=avg_price, symbol=symbol, ) sell_price = costs["execution_price"] sell_amount = sell_price * position @@ -710,149 +1050,22 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float buy_date = None sold_today = True - # 최소 보유 기간 미달 시 신호 매도 차단 (손절/트레일링/익절은 위에서 이미 처리) - if position > 0 and min_holding_days > 0 and buy_date is not None: - holding_days_now = (date - buy_date).days if buy_date is not None else 0 - if holding_days_now < min_holding_days and signal == "SELL": - signal = "HOLD" - - # 월간 거래 횟수 제한: 상한 도달 시 BUY 신호를 HOLD로 차단 - if signal == "BUY" and max_monthly_trades > 0: - try: - _mk = date.strftime("%Y-%m") - except Exception: - _mk = str(date)[:7] - if monthly_trade_counts.get(_mk, 0) >= max_monthly_trades: - signal = "HOLD" - - # 시장국면 필터 (TICKET-02): bearish→매수 차단, caution→포지션 축소 - # regime은 T-1일까지의 지수 종가로 사전 계산됨 (look-ahead bias 없음) - regime_at_t = "bullish" - if regime_enabled and date in regime_series.index: - regime_at_t = regime_series.loc[date] - if signal == "BUY" and regime_at_t == "bearish": - signal = "HOLD" - regime_buy_blocks += 1 - - if signal == "BUY" and gap_enabled and gap_up_entry_block > 0 and previous_close is not None and previous_close > 0: - gap_pct = (float(open_price) - previous_close) / previous_close - if gap_pct >= gap_up_entry_block: - signal = "HOLD" - gap_up_buy_blocks += 1 - - if signal == "BUY" and skip_earnings_days > 0 and _is_near_backtest_earnings(row, date): - signal = "HOLD" - earnings_buy_blocks += 1 - - if signal == "BUY" and bs_enabled and i <= bs_cooldown_until_idx: - signal = "HOLD" - blackswan_buy_blocks += 1 - - # 당일 매도 발생 시 재매수 방지 (같은 봉에서 손절 후 재진입은 비현실적) - if signal == "BUY" and position == 0 and not sold_today: - costs = self.risk_manager.calculate_transaction_costs( - close, 1, "BUY", avg_daily_volume=row_volume - ) - buy_price = costs["execution_price"] - stop_at_buy = buy_price * (1 - sl_rate) - if sl_type == "atr" and row_atr is not None and pd.notna(row_atr) and row_atr > 0: - stop_at_buy = buy_price - float(row_atr) * atr_mult - risk_per_share = max(buy_price - stop_at_buy, buy_price * 0.001) - total_equity = cash + (position * close) - risk_amount = total_equity * max_risk_per_trade - qty_by_1pct = int(risk_amount / risk_per_share) if risk_per_share > 0 else 0 - invest_cap = total_equity * max_position_ratio - qty_by_cap = int(invest_cap / buy_price) if buy_price > 0 else 0 - # 전체 주식 투자 비중 상한 (총자산의 max_investment_ratio 이하) - max_invest_value = total_equity * max_investment_ratio - qty_by_total_cap = int(max_invest_value / buy_price) if buy_price > 0 else 0 - quantity = min( - max(0, qty_by_1pct), - max(0, qty_by_cap), - max(0, qty_by_total_cap), + # legacy 모드는 종가 위험 규칙 이후 같은 종가에 전략 주문을 체결해 + # 과거 결과를 재현한다. next_open 주문은 위에서 이미 처리됐다. + if execution_model == EXECUTION_MODEL_LEGACY_SAME_CLOSE: + _execute_strategy_signal( + signal=signal, + signal_date=signal_date, + strategy_execution_price=strategy_execution_price, + strategy_row_atr=strategy_row_atr, + strategy_row_volume=strategy_row_volume, + regime_at_t=regime_at_t, + date=date, + row=row, + current_idx=i, + previous_close=previous_close, ) - # 시장국면 caution 시 포지션 축소 (TICKET-02) - if quantity > 0 and regime_at_t == "caution": - quantity = max(1, int(quantity * caution_scale)) - regime_caution_buys += 1 - - if ( - quantity > 0 - and bs_enabled - and i > bs_cooldown_until_idx - and i <= bs_recovery_until_idx - and bs_recovery_scale < 1.0 - ): - quantity = max(1, int(quantity * bs_recovery_scale)) - blackswan_recovery_buys += 1 - - # 유동성 필터: 주문량이 일평균 거래량의 N%를 초과하면 축소 또는 차단 - if quantity > 0 and bt_max_participation > 0 and row_volume is not None and row_volume > 0: - max_qty_by_volume = int(row_volume * bt_max_participation) - if max_qty_by_volume <= 0: - quantity = 0 # 유동성 부족 → 매수 불가 - elif quantity > max_qty_by_volume: - quantity = max_qty_by_volume # 유동성 한도까지만 매수 - - if quantity > 0: - buy_costs = self.risk_manager.calculate_transaction_costs( - close, quantity, "BUY", avg_daily_volume=row_volume - ) - buy_price = buy_costs["execution_price"] - buy_amount = buy_price * quantity - commission = buy_costs["commission"] - else: - buy_amount = 0 - commission = 0 - - if quantity > 0 and (buy_amount + commission) <= cash: - cash -= (buy_amount + commission) - position = quantity - avg_price = buy_price - partial_exit_done = False - high_water_mark = buy_price - buy_date = date - trades.append({ - "date": date, "action": "BUY", "price": buy_price, - "quantity": quantity, "pnl": 0, "pnl_rate": 0, - "commission": commission, - "tax": 0.0, - "slippage_cost": _slippage_cost_vs_close(buy_price, close, quantity), - }) - # 월간 거래 횟수 카운트 - if max_monthly_trades > 0: - try: - mk = date.strftime("%Y-%m") - except Exception: - mk = str(date)[:7] - monthly_trade_counts[mk] = monthly_trade_counts.get(mk, 0) + 1 - - elif signal == "SELL" and position > 0: - costs = self.risk_manager.calculate_transaction_costs( - close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price - ) - sell_price = costs["execution_price"] - sell_amount = sell_price * position - commission = costs["commission"] - tax = costs["tax"] + costs.get("capital_gains_tax", 0) - pnl = (sell_price - avg_price) * position - commission - tax - - cash += sell_amount - commission - tax - trades.append({ - "date": date, "action": "SELL", "price": sell_price, - "quantity": position, "pnl": pnl, - "pnl_rate": ((sell_price / avg_price) - 1) * 100, - "commission": commission, - "tax": float(tax), - "slippage_cost": _slippage_cost_vs_close(sell_price, close, position), - }) - position = 0 - avg_price = 0 - high_water_mark = 0.0 - buy_date = None - sold_today = True - # 자본금 곡선 기록 portfolio_value = cash + (position * close) equity_curve.append({ @@ -866,6 +1079,7 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float return { "trades": trades, "equity_curve": pd.DataFrame(equity_curve), + "execution_model": execution_model, "regime_buy_blocks": regime_buy_blocks, "regime_caution_buys": regime_caution_buys, "gap_down_exits": gap_down_exits, @@ -874,6 +1088,8 @@ def _execute_full_exit(action: str, execution_ref_price: float, ref_close: float "blackswan_triggers": blackswan_triggers, "blackswan_buy_blocks": blackswan_buy_blocks, "blackswan_recovery_buys": blackswan_recovery_buys, + "strategy_orders_skipped_missing_open": strategy_orders_skipped_missing_open, + "skipped_strategy_orders": skipped_strategy_orders, } def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: @@ -882,13 +1098,22 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: equity = result["equity_curve"] if equity.empty: - return self._empty_metrics() + metrics = self._empty_metrics() + metrics["execution_model"] = result.get( + "execution_model", EXECUTION_MODEL_NEXT_OPEN + ) + return metrics final_value = equity["value"].iloc[-1] total_return = ((final_value / initial_capital) - 1) * 100 - # 일일 수익률 + # 일일 수익률. 첫 관측값도 초기자본 대비 수익률로 포함해야 첫날 진입비용·손실이 + # Sharpe/VaR에서 사라지지 않는다. equity["daily_return"] = equity["value"].pct_change() + if initial_capital > 0: + equity.loc[equity.index[0], "daily_return"] = ( + float(equity["value"].iloc[0]) / initial_capital + ) - 1.0 daily_returns = equity["daily_return"].dropna() # 샤프 지수 (연율화, 무위험수익률 3%) @@ -941,7 +1166,11 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: cur_consec = 0 # MDD (최대 낙폭) + # 초기자본을 0일차 고점으로 포함한다. 그렇지 않으면 1일차에 10% 손실 후 + # 횡보한 곡선의 MDD가 0%로 잘못 계산된다. equity["peak"] = equity["value"].cummax() + if initial_capital > 0: + equity["peak"] = equity["peak"].clip(lower=initial_capital) equity["drawdown"] = (equity["value"] - equity["peak"]) / equity["peak"] max_drawdown = equity["drawdown"].min() * 100 @@ -1058,6 +1287,9 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: cost_drag = round(gross_return - total_return, 2) metrics = { + "execution_model": result.get( + "execution_model", EXECUTION_MODEL_NEXT_OPEN + ), "total_return": round(total_return, 2), "annual_return": round(annual_return_pct, 2), "cagr": round(cagr, 2), @@ -1100,6 +1332,9 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: "blackswan_triggers": result.get("blackswan_triggers", 0), "blackswan_buy_blocks": result.get("blackswan_buy_blocks", 0), "blackswan_recovery_buys": result.get("blackswan_recovery_buys", 0), + "strategy_orders_skipped_missing_open": result.get( + "strategy_orders_skipped_missing_open", 0 + ), } cost_impact = summarize_cost_impact(metrics, trades) metrics.update(cost_impact_metric_fields(cost_impact)) diff --git a/backtest/paper_compare.py b/backtest/paper_compare.py index e63bb4a8..0f3d70d4 100644 --- a/backtest/paper_compare.py +++ b/backtest/paper_compare.py @@ -252,7 +252,10 @@ def check_live_readiness( account_key = strategy_name or "" snapshots = get_portfolio_snapshots_between( - start_date, end_date, account_key=account_key if account_key else None, + start_date, + end_date, + account_key=account_key if account_key else None, + mode="paper", ) if len(snapshots) < 2: base["message"] = "포트폴리오 스냅샷이 부족하여 실전 전환 평가 불가 (최소 2일 필요)." diff --git a/backtest/portfolio_backtester.py b/backtest/portfolio_backtester.py index 07dff694..02cbe008 100644 --- a/backtest/portfolio_backtester.py +++ b/backtest/portfolio_backtester.py @@ -20,6 +20,21 @@ from core.market_regime import resolve_market_regime_config from core.risk_manager import RiskManager +EXECUTION_MODEL_NEXT_OPEN = "next_open" +EXECUTION_MODEL_LEGACY_SAME_CLOSE = "legacy_same_close" +_SUPPORTED_EXECUTION_MODELS = frozenset( + (EXECUTION_MODEL_NEXT_OPEN, EXECUTION_MODEL_LEGACY_SAME_CLOSE) +) + + +def _validate_execution_model(execution_model: str) -> str: + if execution_model not in _SUPPORTED_EXECUTION_MODELS: + supported = ", ".join(sorted(_SUPPORTED_EXECUTION_MODELS)) + raise ValueError( + f"지원하지 않는 execution_model={execution_model!r}. 지원값: {supported}" + ) + return execution_model + class PortfolioBacktester: """ @@ -46,6 +61,7 @@ def run( trade_start_date: str = None, param_overrides: dict = None, apply_liquidity_filter: bool = True, + execution_model: str = EXECUTION_MODEL_NEXT_OPEN, ) -> dict: """ 멀티종목 포트폴리오 백테스트 실행. @@ -60,6 +76,9 @@ def run( param_overrides: 전략 파라미터 덮어쓰기. 예: {"relative_strength_rotation": {"short_lookback": 40}} apply_liquidity_filter: true면 run 진입 시점에 20일 평균 거래대금 기준으로 universe 사전 제외 + execution_model: 전략 신호 체결 모델. 기본 ``next_open``은 T일 종가로 + 확정된 BUY/SELL을 다음 거래일 시가에 체결한다. 과거 결과 재현용 + ``legacy_same_close``도 명시적으로 선택할 수 있다. Returns: 포트폴리오 수준 백테스트 결과 @@ -72,6 +91,8 @@ def run( "position_sizing", {} ).get("initial_capital", 10000000) + execution_model = _validate_execution_model(execution_model) + original_symbols = list(symbols) if apply_liquidity_filter: from core.watchlist_manager import WatchlistManager @@ -175,6 +196,7 @@ def run( min_hold_days=min_hold_days, disable_trailing_stop=disable_trailing_stop, tp_rate_override=float(tp_override) if tp_override is not None else None, + execution_model=execution_model, ) metrics = self._calculate_portfolio_metrics(result, initial_capital) @@ -194,6 +216,7 @@ def run( "input_symbols": original_symbols, "liquidity_filter": liquidity_filter, "initial_capital": initial_capital, + "execution_model": execution_model, "per_symbol_stats": result.get("per_symbol_stats", {}), # 진단 계측 전달 "exit_reason_counts": result.get("exit_reason_counts", {}), @@ -349,7 +372,9 @@ def _simulate_portfolio( min_hold_days: int = 0, disable_trailing_stop: bool = False, tp_rate_override: float = None, + execution_model: str = EXECUTION_MODEL_NEXT_OPEN, ) -> dict: + execution_model = _validate_execution_model(execution_model) cash = initial_capital positions = {} # symbol -> {qty, avg_price, buy_date, high_water_mark} trades = [] @@ -482,6 +507,20 @@ def _index_position(sig_df: pd.DataFrame, date) -> int | None: return None return pos + def _strategy_signal_context( + sym: str, date + ) -> tuple[pd.Series | None, object | None]: + """체결일 기준으로 사용할 전략 신호 행과 실제 신호일을 반환한다.""" + sig_df = signals.get(sym) + pos = _index_position(sig_df, date) + if pos is None: + return None, None + if execution_model == EXECUTION_MODEL_NEXT_OPEN: + pos -= 1 + if pos < 0: + return None, None + return sig_df.iloc[pos], sig_df.index[pos] + def _previous_close(sym: str, date) -> float | None: sig_df = signals.get(sym) if sig_df is None or "close" not in sig_df.columns: @@ -595,6 +634,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: sell_reason = None sell_price_ref = close + sell_signal_date = None avg_daily_volume = _avg_daily_volume(sig_df, date, row) row_atr = _get_atr(sig_df, date) hd = (date - pos["buy_date"]).days if pos.get("buy_date") and hasattr(date, "date") else 0 @@ -648,18 +688,40 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: sell_reason = None # 냉각기: TRAILING_STOP 억제 else: sell_reason = "TRAILING_STOP" - if not sell_reason and row.get("signal") == "SELL": + strategy_row, strategy_signal_date = _strategy_signal_context(sym, date) + if ( + not sell_reason + and strategy_row is not None + and strategy_row.get("signal") == "SELL" + ): if in_cooling: sell_reason = None # 냉각기: 전략 SELL 억제 else: sell_reason = "SELL" + sell_signal_date = strategy_signal_date + sell_price_ref = ( + open_price + if execution_model == EXECUTION_MODEL_NEXT_OPEN + else close + ) + avg_daily_volume = _avg_daily_volume( + sig_df, strategy_signal_date, strategy_row + ) if sell_reason: - to_sell.append((sym, sell_price_ref, sell_reason, avg_daily_volume)) + to_sell.append( + ( + sym, + sell_price_ref, + sell_reason, + avg_daily_volume, + sell_signal_date, + ) + ) exit_reason_counts[sell_reason] = exit_reason_counts.get(sell_reason, 0) + 1 executed_sell_count += len(to_sell) - for sym, close, reason, avg_daily_volume in to_sell: + for sym, close, reason, avg_daily_volume, signal_date in to_sell: pos = positions.pop(sym) costs = self.risk_manager.calculate_transaction_costs( close, @@ -667,6 +729,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: "SELL", avg_daily_volume=avg_daily_volume, avg_price=pos["avg_price"], + symbol=sym, ) sell_price = costs["execution_price"] tax_amt = costs["tax"] + costs.get("capital_gains_tax", 0) @@ -674,7 +737,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: cash += sell_price * pos["qty"] - costs["commission"] - tax_amt per_symbol_pnl[sym] = per_symbol_pnl.get(sym, 0) + pnl holding_days = (date - pos["buy_date"]).days if pos.get("buy_date") and hasattr(date, "date") else 0 - trades.append({ + trade = { "date": date, "symbol": sym, "action": reason, "price": sell_price, "quantity": pos["qty"], "pnl": pnl, "pnl_rate": ((sell_price / pos["avg_price"]) - 1) * 100, @@ -688,7 +751,10 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: "score_bollinger": pos.get("score_bollinger", 0), "score_volume": pos.get("score_volume", 0), "holding_days": holding_days, - }) + } + if signal_date is not None: + trade["signal_date"] = signal_date + trades.append(trade) # 시장국면 판별 (TICKET-05): T-1일 지수 기준, look-ahead bias 없음 regime_at_t = "bullish" @@ -716,59 +782,84 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: sig_df = signals.get(sym) if sig_df is None or date not in sig_df.index: continue - row = sig_df.loc[date] - if row.get("signal") == "BUY": - close = float(row.get("close", 0)) + current_pos = _index_position(sig_df, date) + if current_pos is None: + continue + execution_row = sig_df.iloc[current_pos] + strategy_row, signal_date = _strategy_signal_context(sym, date) + if strategy_row is not None and strategy_row.get("signal") == "BUY": + close = float(execution_row.get("close", 0)) prev_close = _previous_close(sym, date) - open_price = _row_price(row, "open", close) + open_price = _row_price(execution_row, "open", close) + execution_price_ref = ( + open_price + if execution_model == EXECUTION_MODEL_NEXT_OPEN + else close + ) if gap_enabled and gap_up_entry_block > 0 and prev_close is not None and prev_close > 0: gap_pct = (open_price - prev_close) / prev_close if gap_pct >= gap_up_entry_block: gap_up_buy_blocks += 1 skipped_reasons["gap_up_entry_block"] = skipped_reasons.get("gap_up_entry_block", 0) + 1 - _record_blocked_buy(sym, row, "gap_up_entry_block") + _record_blocked_buy(sym, strategy_row, "gap_up_entry_block") continue - if skip_earnings_days > 0 and _is_near_backtest_earnings(row, date): + if skip_earnings_days > 0 and _is_near_backtest_earnings(execution_row, date): earnings_buy_blocks += 1 skipped_reasons["earnings_window"] = skipped_reasons.get("earnings_window", 0) + 1 - _record_blocked_buy(sym, row, "earnings_window") + _record_blocked_buy(sym, strategy_row, "earnings_window") continue if bs_enabled and date_idx <= bs_cooldown_until_idx: blackswan_buy_blocks += 1 skipped_reasons["blackswan_cooldown"] = skipped_reasons.get("blackswan_cooldown", 0) + 1 - _record_blocked_buy(sym, row, "blackswan_cooldown") + _record_blocked_buy(sym, strategy_row, "blackswan_cooldown") continue - score = float(row.get("total_score", row.get("strategy_score", 0))) - buy_candidates.append((sym, close, score)) + score = float( + strategy_row.get( + "total_score", strategy_row.get("strategy_score", 0) + ) + ) + buy_candidates.append( + ( + sym, + execution_price_ref, + score, + strategy_row, + signal_date, + ) + ) else: - # bearish: 모든 BUY 신호 차단. 차단 수 집계 (종목 무관하게 날짜 1회) - n_blocked = sum( - 1 for sym in symbols - if sym not in positions - and signals.get(sym) is not None - and date in signals[sym].index - and signals[sym].loc[date].get("signal") == "BUY" - ) + # bearish: 오늘 체결될 예약 BUY 신호를 모두 차단한다. + blocked_pending = [] + for sym in symbols: + if sym in positions: + continue + strategy_row, signal_date = _strategy_signal_context(sym, date) + if strategy_row is not None and strategy_row.get("signal") == "BUY": + blocked_pending.append((sym, strategy_row, signal_date)) + n_blocked = len(blocked_pending) regime_buy_blocks += n_blocked skipped_reasons["regime_bearish"] = skipped_reasons.get("regime_bearish", 0) + n_blocked if n_blocked > 0 and len(blocked_buy_examples) < 10: - for sym in symbols: - if sym not in positions and signals.get(sym) is not None and date in signals[sym].index: - r = signals[sym].loc[date] - if r.get("signal") == "BUY": - blocked_buy_examples.append({ - "date": str(date)[:10], "symbol": sym, - "score": float(r.get("total_score", r.get("strategy_score", 0))), - "reason": "bearish", - }) + for sym, strategy_row, _signal_date in blocked_pending: + if len(blocked_buy_examples) >= 10: + break + blocked_buy_examples.append({ + "date": str(date)[:10], "symbol": sym, + "score": float( + strategy_row.get( + "total_score", strategy_row.get("strategy_score", 0) + ) + ), + "reason": "bearish", + }) buy_candidates.sort(key=lambda x: -x[2]) - for sym, close, score in buy_candidates: + for sym, execution_price_ref, score, sig_row, signal_date in buy_candidates: if len(positions) >= max_positions: skipped_reasons["max_positions"] = skipped_reasons.get("max_positions", 0) + 1 continue @@ -792,13 +883,18 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: continue max_invest = total_equity_now * max_position_ratio - buy_atr = _get_atr(signals.get(sym), date) - stop_at_buy = _stop_loss_price(close, buy_atr) - risk_per_share = max(close - stop_at_buy, close * 0.001) + buy_atr = _get_atr(signals.get(sym), signal_date) + stop_at_buy = _stop_loss_price(execution_price_ref, buy_atr) + risk_per_share = max( + execution_price_ref - stop_at_buy, + execution_price_ref * 0.001, + ) risk_amount = total_equity_now * self.risk_params.get("position_sizing", {}).get("max_risk_per_trade", 0.01) qty = min( int(risk_amount / risk_per_share), - int(max_invest / close) if close > 0 else 0, + int(max_invest / execution_price_ref) + if execution_price_ref > 0 + else 0, ) scale = self.risk_manager._signal_scale(score) @@ -832,24 +928,26 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: blackswan_recovery_buys += 1 if qty > 0 and notional_bucket == "caution": - caution_buy_notionals.append(close * qty) + caution_buy_notionals.append(execution_price_ref * qty) elif qty > 0 and notional_bucket == "bullish": - bullish_buy_notionals.append(close * qty) + bullish_buy_notionals.append(execution_price_ref * qty) if qty <= 0: skipped_reasons["qty_zero"] = skipped_reasons.get("qty_zero", 0) + 1 continue - if close * qty > cash * 0.95: + if execution_price_ref * qty > cash * 0.95: skipped_reasons["no_cash"] = skipped_reasons.get("no_cash", 0) + 1 continue - sig_row = signals[sym].loc[date] - avg_daily_volume = _avg_daily_volume(signals.get(sym), date, sig_row) + avg_daily_volume = _avg_daily_volume( + signals.get(sym), signal_date, sig_row + ) costs = self.risk_manager.calculate_transaction_costs( - close, + execution_price_ref, qty, "BUY", avg_daily_volume=avg_daily_volume, + symbol=sym, ) buy_price = costs["execution_price"] total_cost = buy_price * qty + costs["commission"] @@ -875,6 +973,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: } trades.append({ "date": date, "symbol": sym, "action": "BUY", + "signal_date": signal_date, "price": buy_price, "quantity": qty, "pnl": 0, "pnl_rate": 0, "commission": costs["commission"], "tax": 0.0, @@ -909,6 +1008,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: return { "trades": trades, "equity_curve": pd.DataFrame(equity_curve), + "execution_model": execution_model, "per_symbol_stats": per_symbol_stats, "regime_buy_blocks": regime_buy_blocks, "regime_caution_buys": regime_caution_buys, @@ -944,12 +1044,23 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> trades = result["trades"] if equity.empty: - return {"total_return": 0, "sharpe_ratio": 0, "max_drawdown": 0} + return { + "execution_model": result.get( + "execution_model", EXECUTION_MODEL_NEXT_OPEN + ), + "total_return": 0, + "sharpe_ratio": 0, + "max_drawdown": 0, + } final_value = equity["value"].iloc[-1] total_return = ((final_value / initial_capital) - 1) * 100 equity["daily_return"] = equity["value"].pct_change() + if initial_capital > 0: + equity.loc[equity.index[0], "daily_return"] = ( + float(equity["value"].iloc[0]) / initial_capital + ) - 1.0 daily_returns = equity["daily_return"].dropna() if len(daily_returns) > 0 and daily_returns.std() > 0: @@ -966,6 +1077,8 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> sortino = sharpe equity["peak"] = equity["value"].cummax() + if initial_capital > 0: + equity["peak"] = equity["peak"].clip(lower=initial_capital) equity["drawdown"] = (equity["value"] - equity["peak"]) / equity["peak"] max_drawdown = equity["drawdown"].min() * 100 @@ -997,6 +1110,9 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> total_transaction_cost = round(total_commission + total_tax + total_slippage_cost, 0) metrics = { + "execution_model": result.get( + "execution_model", EXECUTION_MODEL_NEXT_OPEN + ), "total_return": round(total_return, 2), "annual_return": round(annual_return_pct, 2), "sharpe_ratio": round(sharpe, 2), diff --git a/config/baskets.yaml b/config/baskets.yaml index 65315ae4..cdf20c30 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -37,31 +37,75 @@ baskets: # 2026-06-10 paper 운영 개시: trading.mode=paper에서 트랙레코드(60영업일) 축적 목적. # paper에서는 실주문이 없고, live 전환은 별도로 ENABLE_LIVE_TRADING + --confirm-live + # 바스켓별 readiness gate(basket_rebalance:kr_diversified_hold)를 모두 통과해야 한다. + # 2026-08-07 튜닝: 운영 2개월 실측을 반영해 두 가지를 명시로 바꿨다. + # (1) target_stock_weight 0.60 — 종전에는 '주식 80%(=1-min_cash_ratio)'가 설계였는데 + # 실효 배치율은 59%였다(하이닉스 슬롯 미충족 + 정수주 절사 + 추가매수 영구 거부 + # 버그). 그 결과 6/10~8/07 KOSPI -18.6% 구간에서 총자산은 -5.0%로 막혔다 — + # 현금 완충이 방어의 주원인이었다. 그 완충을 '버그의 부작용'으로 두지 않고 + # 의도한 정책으로 고정한다. 상승 전환 시 80% 대비 수익은 덜 먹는 대가다. + # (2) risk 블록 — 종전에는 전역 단타 기준(-3% 손절/+8% 익절/-5% 트레일링)이 진입 + # 시 기록되기만 하고 일일 사이클이 평가하지 않아, 손절선을 뚫은 포지션이 그대로 + # 방치됐다. 트랙 성격에 맞는 폭으로 다시 잡고 실제로 평가한다(core/basket_risk.py). kr_diversified_hold: name: "분산 대형주 buy&hold (저회전)" enabled: true - # 관찰용 강등(docs/POCKET_TRACK_PLAN.md §6): 소액 전환으로 자본 증액 결정이 종결돼 - # 배치율 미달(-21%p, 하이닉스 슬롯+절사)은 조치 대상이 아니다 — 상시 ATTENTION은 - # 경보 피로만 남기고 다른 바스켓의 배치율 이탈을 가린다. 감시 사실상 해제(1.0). + primary: false + purpose: "장기 관찰용" + target_stock_weight: 0.60 # 주식 60% / 현금 40% — 낙폭 완충을 설계로 고정 + risk: + # buy&hold 트랙의 손절은 '추세가 꺾였다'가 아니라 '한 종목이 포트폴리오를 + # 망가뜨리는 것'을 막는 장치다. 대형주 정상 조정(-10%대)에는 안 걸리고 + # 단일 종목 대형 손실만 잘라내도록 넓게 잡는다. 실측 참고: 이 구간 최악은 + # 현대차 -34.8%였고, 정상 조정권은 -10~-17%대에 몰려 있었다. + stop_loss_pct: 0.25 + take_profit_pct: 0 # 익절 없음 — 장기 보유가 이 트랙의 전제 + trailing_stop_pct: 0 # 트레일링 없음 — 저회전 유지 + # 손절한 종목의 재매수를 막는 기간. 0이면 손절이 무의미해진다 — 비워진 슬롯을 + # 같은 사이클의 비중 교정이 곧바로 되사기 때문이다(2026-08-07 10:07에 실제로 + # 현대차를 손절 4초 뒤 되사는 왕복매매가 발생). 분기 리밸런싱 성격에 맞춰 60일. + reentry_cooldown_days: 60 monitoring: - deployment_tolerance: 1.0 + # 2026-08-26: 1.0(감시 사실상 해제)에서 복원. 1.0으로 둔 이유는 '하이닉스 슬롯을 + # 못 채워 생기는 -21%p 미달은 조치 불가'였는데, 그 슬롯을 제거하고 목표 배치율을 + # 60%로 명시한 지금은 미달이 곧 조치 대상(현금 누수)이다. 실제로 감시가 꺼져 있는 + # 동안 배치율이 61.0% → 54.9%로 새는 것을 헬스가 전혀 잡지 못했다. + deployment_tolerance: 0.05 rebalance: trigger: drift # 분기 점검을 권장하나 drift로 큰 이탈만 교정 drift_threshold: 0.08 # 8%p 이상 이탈 시에만 리밸런싱(잦은 매매 억제) min_trade_amount: 200000 max_turnover_ratio: 0.15 # 1회 회전 상한 15%(비용 최소화) - holdings: # 섹터 분산 10종목 균등(각 10%) - "005930": 0.10 # 삼성전자 (반도체) - "000660": 0.10 # SK하이닉스 (반도체) - "035420": 0.10 # NAVER (인터넷) - "005380": 0.10 # 현대차 (자동차) - "051910": 0.10 # LG화학 (화학/배터리) - "005490": 0.10 # POSCO홀딩스 (철강) - "055550": 0.10 # 신한지주 (금융) - "035720": 0.10 # 카카오 (인터넷) - "012330": 0.10 # 현대모비스 (자동차부품) - "105560": 0.10 # KB금융 (금융) - # 합계 = 1.00 + # 종목별 드리프트가 전부 임계값 아래여도 집계 배치율이 설계에서 이만큼 벗어나면 + # 리밸런싱한다. 없으면 매도로 늘어난 현금이 영영 재투자되지 않는다(현금 래칫). + deployment_band: 0.03 + # 섹터 분산 9종목 균등(각 1/9 ≈ 11.1%). + # 000660(SK하이닉스) 제외 — 자본 규모상 이 트랙에서 보유가 불가능하다. + # 1주 약 148만원인데 주식 슬리브(자본 1,000만 × 60% = 600만) 안에서 1주를 담으려면 + # 슬리브의 26%가 필요하고, 이는 단일 종목 상한(max_position_ratio 20%)을 넘는다. + # 그래서 6/10 진입 이래 이 슬롯은 한 번도 못 채워졌고(매 사이클 '채움 불가' 경고), + # 목표 비중표에 남겨 두면 영영 못 채우는 10% 공백이 배치율 미달로만 나타난다. + # 자본을 약 2,500만원 이상으로 올리면 다시 편입 가능하다(그때는 비중 재산정). + holdings: + "005930": 0.1111 # 삼성전자 (반도체) + "035420": 0.1111 # NAVER (인터넷) + "005380": 0.1111 # 현대차 (자동차) + "051910": 0.1111 # LG화학 (화학/배터리) + "005490": 0.1111 # POSCO홀딩스 (철강) + "055550": 0.1111 # 신한지주 (금융) + "035720": 0.1111 # 카카오 (인터넷) + "012330": 0.1111 # 현대모비스 (자동차부품) + "105560": 0.1112 # KB금융 (금융) + # 합계 = 1.00 (get_target_weights가 합 1.0으로 정규화하므로 절사 오차는 무해) + holding_names: + "005930": "삼성전자" + "035420": "NAVER" + "005380": "현대차" + "051910": "LG화학" + "005490": "POSCO홀딩스" + "055550": "신한지주" + "035720": "카카오" + "012330": "현대모비스" + "105560": "KB금융" # --- 균형 배분(주식 50% + 현금 50%) — 안정성 우선 --- # docs/STATIC_ALLOCATION.md 결론을 실행 기능으로 구현. target_stock_weight로 주식 비중을 @@ -92,13 +136,14 @@ baskets: # # v2 (2026-07-07): 위험자산 절반 원칙은 유지하되 "나머지 절반"을 무수익 현금 대신 # CD금리 파킹 ETF로 보유한다. 종전 50/50에서 현금 50%는 이자 0으로 잠들어 있었는데, - # 357870은 CD 91일물 금리를 일할 누적하는 무분배(TR형) ETF라 가격 변동이 사실상 - # 없이 연 3%대가 쌓인다 — 위험은 거의 그대로, 유휴분만 회수(연 +1%대 기대). + # 357870은 CD 91일물 금리 지수를 추종하는 무분배(TR형) ETF다. 수익률은 당시 + # CD금리에 따라 변하며 원금 보장 상품이 아니다. 합성 거래상대방·유동성 위험도 남는다. # - 주가지수(069500) 47.5% + 파킹(357870) 47.5% + 현금 5%(수수료 여유) # - min_cash_ratio 0.05: 전역 20% 하한은 개별 주식 바스켓용 안전판 — 보유분 # 절반이 현금성인 이 바스켓엔 과잉이라 바스켓별 오버라이드로 낮춘다. - # - 한계 고지: KODEX200 분배금은 페이퍼가 못 받는다(연 ~1.5-2% × 47.5% 보수적 - # 편향). 357870은 무분배라 누락 없음. 합성 ETF의 스왑 상대방 위험은 미미. + # - 한계 고지: KODEX200 분배금은 페이퍼가 못 받아 수익이 보수적으로 잡힌다. + # 357870은 무분배지만 매매차익에 보유기간 과세가 적용되므로, 과표기준가가 없는 + # 현재 모델은 양의 매매차익 전액 × 15.4%를 세금 상한으로 차감한다. # 파라미터 주의(소액 특화): # - max_turnover_ratio 0.6: 15%면 회전 한도(30만×15%=4.5만)가 ETF 1주가(약 12.4만)보다 # 작아 영원히 매수 불가. 0.6이면 첫 사이클에 큰 슬롯부터 채운다(잔여는 다음 날). @@ -108,6 +153,12 @@ baskets: kr_pocket: name: "소액 적립 (지수+CD금리 파킹)" enabled: true + primary: true + purpose: "월 적립 중심" + contribution_plan: + enabled: true + cadence: "monthly" + amount: 100000 initial_capital: 300000 # 시작 자본 30만원 (paper — live 전환 시 실자본과 일치) target_stock_weight: 0.95 # 투자 95% (지수 47.5 + 파킹 47.5) / 현금 5% min_cash_ratio: 0.05 # 바스켓별 현금 하한 오버라이드 (전역 0.20 대체) @@ -117,14 +168,28 @@ baskets: # (1주가/총액)과의 max로 자동 완화·조임한다 — 잔고가 커지면 이 값이 다시 지배한다. monitoring: deployment_tolerance: 0.10 + # 이 트랙의 리스크 정책은 '손절 없음'이며, 이는 누락이 아니라 결정이다. + # 069500은 200종목 분산 지수 ETF고 이 트랙은 월 적립(DCA) 엔진이다. 지수에 + # 손절을 걸면 하락을 손실로 확정하면서 회복 구간을 통째로 놓치고, 적립식이 + # 하락에서 단가를 낮추는 효과까지 없앤다. 357870은 CD금리 파킹이라 손절 대상이 + # 아니다. 이 트랙의 실제 위험 통제는 위험자산을 절반(47.5%)으로 묶는 비중 자체다. + # (전역 단타 -3% 손절이 진입 시 기록되던 것을 여기서 명시로 끈다.) + risk: + stop_loss_pct: 0 + take_profit_pct: 0 + trailing_stop_pct: 0 rebalance: trigger: drift drift_threshold: 0.08 min_trade_amount: 50000 max_turnover_ratio: 0.6 + deployment_band: 0.03 holdings: "069500": 0.5 # KODEX 200 — 위험자산 슬리브(총자산의 47.5%) "357870": 0.5 # TIGER CD금리투자KIS(합성) — 파킹 슬리브(총자산의 47.5%) + holding_names: + "069500": "KODEX 200" + "357870": "TIGER CD금리투자KIS(합성)" # --- 예시 2: 신호 가중 리밸런싱 바스켓 --- # signal_weighted: true 시, 스코어링 전략 점수에 따라 목표 비중을 동적 조정 diff --git a/config/config_loader.py b/config/config_loader.py index b321d019..dbef4339 100644 --- a/config/config_loader.py +++ b/config/config_loader.py @@ -7,9 +7,11 @@ import hashlib import logging +import math import os import yaml from pathlib import Path +from yaml.resolver import BaseResolver # 프로젝트 루트 디렉토리 (config/ 의 상위) PROJECT_ROOT = Path(__file__).parent.parent @@ -17,6 +19,41 @@ ENV_PATH = PROJECT_ROOT / ".env" +class _UniqueKeySafeLoader(yaml.SafeLoader): + """SafeLoader이면서 같은 mapping 안의 중복 키를 즉시 거부한다.""" + + +def _construct_unique_mapping(loader, node, deep=False): + loader.flatten_mapping(node) + mapping = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in mapping + except TypeError as exc: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from exc + if duplicate: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + f"found duplicate key ({key!r})", + key_node.start_mark, + ) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +_UniqueKeySafeLoader.add_constructor( + BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + def _load_env_file_fallback(env_path: Path = ENV_PATH) -> None: """python-dotenv가 없어도 프로젝트 .env의 단순 KEY=value 항목을 로드한다.""" if not env_path.exists(): @@ -48,7 +85,7 @@ def _load_env_file_fallback(env_path: Path = ENV_PATH) -> None: def load_yaml(file_path: str) -> dict: """YAML 파일을 읽어 딕셔너리로 반환""" with open(file_path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} + return yaml.load(f, Loader=_UniqueKeySafeLoader) or {} def _override_with_env(settings: dict) -> dict: @@ -335,26 +372,268 @@ def _validate_critical_params(self): _log = logging.getLogger("config_loader") errors = [] + def _is_finite_number(value) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ) + + def _validate_ratio(path: str, value, *, allow_zero: bool = False) -> None: + if value is None: + return + if not _is_finite_number(value): + lower_ok = False + elif allow_zero: + lower_ok = value >= 0 + else: + lower_ok = value > 0 + if not _is_finite_number(value) or not lower_ok or value > 1: + bracket = "0 ≤ x ≤ 1" if allow_zero else "0 < x ≤ 1" + errors.append(f"{path} 범위 오류 ({bracket}): {value!r}") + + def _validate_bool(path: str, value) -> None: + if value is not None and not isinstance(value, bool): + errors.append(f"{path}는 boolean이어야 합니다: {value!r}") + # risk_params 필수 키 ps = self._risk_params.get("position_sizing", {}) ic = ps.get("initial_capital") - if ic is None or not isinstance(ic, (int, float)) or ic <= 0: + if ic is None or not _is_finite_number(ic) or ic <= 0: errors.append(f"risk_params.position_sizing.initial_capital이 유효하지 않습니다: {ic!r}") + max_risk = ps.get("max_risk_per_trade") + if ( + max_risk is None + or not _is_finite_number(max_risk) + or max_risk <= 0 + or max_risk > 0.05 + ): + errors.append( + "risk_params.position_sizing.max_risk_per_trade 범위 오류 " + f"(0 < x ≤ 0.05 하드캡): {max_risk!r}" + ) + rr = ps.get("risk_ratio") - if rr is not None and (not isinstance(rr, (int, float)) or rr <= 0 or rr > 1): - errors.append(f"risk_params.position_sizing.risk_ratio 범위 오류 (0 < x ≤ 1): {rr!r}") + _validate_ratio("risk_params.position_sizing.risk_ratio", rr) + + signal_scaling = ps.get("signal_scaling") or {} + if signal_scaling.get("enabled", False): + min_scale = signal_scaling.get("min_scale") + max_scale = signal_scaling.get("max_scale") + _validate_ratio("risk_params.position_sizing.signal_scaling.min_scale", min_scale) + _validate_ratio("risk_params.position_sizing.signal_scaling.max_scale", max_scale) + if ( + _is_finite_number(min_scale) + and _is_finite_number(max_scale) + and min_scale > max_scale + ): + errors.append( + "risk_params.position_sizing.signal_scaling은 min_scale ≤ max_scale 이어야 합니다: " + f"{min_scale!r} > {max_scale!r}" + ) # drawdown 한도 dd = self._risk_params.get("drawdown", {}) mdd = dd.get("max_portfolio_mdd") - if mdd is not None and (not isinstance(mdd, (int, float)) or mdd <= 0 or mdd > 1): - errors.append(f"risk_params.drawdown.max_portfolio_mdd 범위 오류 (0 < x ≤ 1): {mdd!r}") + _validate_ratio("risk_params.drawdown.max_portfolio_mdd", mdd) + _validate_ratio("risk_params.drawdown.max_daily_loss", dd.get("max_daily_loss")) + + diversification = self._risk_params.get("diversification", {}) + for key in ("max_position_ratio", "max_investment_ratio", "max_sector_ratio"): + _validate_ratio(f"risk_params.diversification.{key}", diversification.get(key)) + _validate_ratio( + "risk_params.diversification.min_cash_ratio", + diversification.get("min_cash_ratio"), + allow_zero=True, + ) + _validate_bool( + "risk_params.diversification.sector_map_strict", + diversification.get("sector_map_strict"), + ) + max_positions = diversification.get("max_positions") + if max_positions is not None and ( + not isinstance(max_positions, int) + or isinstance(max_positions, bool) + or max_positions < 1 + ): + errors.append( + "risk_params.diversification.max_positions는 1 이상의 정수여야 합니다: " + f"{max_positions!r}" + ) - # trading 모드 - mode = (self._settings.get("trading") or {}).get("mode", "paper") + correlation = diversification.get("correlation_risk") or {} + _validate_bool( + "risk_params.diversification.correlation_risk.enabled", + correlation.get("enabled"), + ) + _validate_bool( + "risk_params.diversification.correlation_risk.strict", + correlation.get("strict"), + ) + if correlation.get("enabled", False): + threshold = correlation.get("high_corr_threshold") + scale = correlation.get("high_corr_scale") + _validate_ratio( + "risk_params.diversification.correlation_risk.high_corr_threshold", + threshold, + ) + _validate_ratio( + "risk_params.diversification.correlation_risk.high_corr_scale", + scale, + ) + lookback = correlation.get("lookback_days") + if ( + not isinstance(lookback, int) + or isinstance(lookback, bool) + or lookback < 30 + ): + errors.append( + "risk_params.diversification.correlation_risk.lookback_days는 " + f"30 이상의 정수여야 합니다: {lookback!r}" + ) + + gap_risk = self._risk_params.get("gap_risk") or {} + _validate_bool("risk_params.gap_risk.enabled", gap_risk.get("enabled")) + if gap_risk.get("enabled", False): + gap_down = gap_risk.get("gap_down_threshold") + if ( + not _is_finite_number(gap_down) + or not (-1 < float(gap_down) < 0) + ): + errors.append( + "risk_params.gap_risk.gap_down_threshold 범위 오류 " + f"(-1 < x < 0): {gap_down!r}" + ) + gap_up = gap_risk.get("gap_up_entry_block") + _validate_ratio("risk_params.gap_risk.gap_up_entry_block", gap_up) + + performance = self._risk_params.get("performance_degradation") or {} + _validate_bool( + "risk_params.performance_degradation.enabled", + performance.get("enabled"), + ) + if performance.get("enabled", False): + _validate_ratio( + "risk_params.performance_degradation.min_win_rate", + performance.get("min_win_rate"), + ) + recent_trades = performance.get("recent_trades") + if ( + not isinstance(recent_trades, int) + or isinstance(recent_trades, bool) + or recent_trades < 5 + ): + errors.append( + "risk_params.performance_degradation.recent_trades는 " + f"5 이상의 정수여야 합니다: {recent_trades!r}" + ) + + liquidity = self._risk_params.get("liquidity_filter") or {} + for key in ("enabled", "strict", "check_on_entry"): + _validate_bool( + f"risk_params.liquidity_filter.{key}", + liquidity.get(key), + ) + + blackswan = self._risk_params.get("blackswan", {}) or {} + for key in ( + "single_stock_threshold", + "portfolio_threshold", + "consecutive_threshold", + ): + value = blackswan.get(key) + if value is not None and ( + not _is_finite_number(value) or value >= 0 or value <= -1 + ): + errors.append( + f"risk_params.blackswan.{key} 범위 오류 (-1 < x < 0): {value!r}" + ) + consecutive_days = blackswan.get("consecutive_days") + if consecutive_days is not None and ( + not isinstance(consecutive_days, int) + or isinstance(consecutive_days, bool) + or consecutive_days < 1 + ): + errors.append( + "risk_params.blackswan.consecutive_days는 1 이상의 정수여야 합니다: " + f"{consecutive_days!r}" + ) + for key in ("cooldown_minutes", "recovery_minutes"): + value = blackswan.get(key) + if value is not None and ( + not isinstance(value, int) + or isinstance(value, bool) + or value < 0 + ): + errors.append( + f"risk_params.blackswan.{key}는 0 이상의 정수여야 합니다: {value!r}" + ) + _validate_ratio( + "risk_params.blackswan.recovery_scale", + blackswan.get("recovery_scale"), + allow_zero=True, + ) + + holding_tax = ( + (self._risk_params.get("transaction_costs") or {}) + .get("holding_period_income_tax") + or {} + ) + if holding_tax.get("enabled", False): + _validate_ratio( + "risk_params.transaction_costs.holding_period_income_tax.rate", + holding_tax.get("rate"), + ) + holding_symbols = holding_tax.get("symbols") + if not isinstance(holding_symbols, list) or not holding_symbols: + errors.append( + "risk_params.transaction_costs.holding_period_income_tax.symbols는 " + "비어 있지 않은 목록이어야 합니다." + ) + + # trading 모드와 주문 가드 설정 + trading = self._settings.get("trading") or {} + mode = trading.get("mode", "paper") if mode not in ("paper", "live", "backtest", "schedule"): errors.append(f"settings.trading.mode가 유효하지 않습니다: {mode!r}") + _validate_bool("settings.trading.auto_entry", trading.get("auto_entry")) + for key, minimum in ( + ("pending_order_ttl_seconds", 60), + ("ledger_reconcile_guard_ttl_seconds", 3_600), + ("skip_earnings_days", 0), + ): + value = trading.get(key) + if value is not None and ( + not isinstance(value, int) + or isinstance(value, bool) + or value < minimum + ): + errors.append( + f"settings.trading.{key}는 {minimum} 이상의 정수여야 합니다: " + f"{value!r}" + ) + + kis = self._settings.get("kis_api") or {} + _validate_bool("settings.kis_api.use_mock", kis.get("use_mock")) + max_per_sec = kis.get("max_calls_per_sec") + if max_per_sec is not None and ( + not _is_finite_number(max_per_sec) or max_per_sec <= 0 + ): + errors.append( + "settings.kis_api.max_calls_per_sec는 유한한 양수여야 합니다: " + f"{max_per_sec!r}" + ) + max_per_min = kis.get("max_calls_per_min") + if max_per_min is not None and ( + not isinstance(max_per_min, int) + or isinstance(max_per_min, bool) + or max_per_min < 1 + ): + errors.append( + "settings.kis_api.max_calls_per_min는 1 이상의 정수여야 합니다: " + f"{max_per_min!r}" + ) if errors: msg = "설정 검증 실패:\n - " + "\n - ".join(errors) @@ -517,31 +796,25 @@ def resolved_hash(self) -> str: """환경변수 반영 후 실행 설정 해시.""" return self._resolved_hash - # live에서 기본 계좌 폴백 경고를 전략당 1회만 내기 위한 기록 (프로세스 전역) - _default_account_warned: set = set() - def get_account_no(self, strategy: str = "") -> str: """ 전략에 해당하는 계좌번호 반환 (다중 계좌 분리). kis_api.accounts에 전략명이 있으면 해당 계좌, 없으면 kis_api.account_no(기본) 사용. - live 모드에서 전략 키가 미선언/빈 값이라 기본 계좌로 폴백하면 경고를 남긴다 — - 침묵 폴백은 여러 전략·바스켓이 모르게 같은 실계좌(자본 풀)를 공유하게 만들고, - DB상 account_key는 서로 달라 보여 공유 사실이 가려진다. + live 모드에서는 전략 키가 미선언/빈 값이면 차단한다. 여러 전략이 모르게 + 기본 실계좌를 공유하면 전략별 DB 장부와 실제 자본 풀이 달라져 노출 한도가 + 무력화될 수 있다. 같은 계좌를 의도했다면 accounts에 명시적으로 중복 선언한다. """ kis = self.kis_api accounts = kis.get("accounts", {}) or {} if strategy and strategy in accounts and accounts[strategy]: return accounts[strategy] if strategy and str(self.trading.get("mode", "paper")).lower() == "live": - if strategy not in Config._default_account_warned: - Config._default_account_warned.add(strategy) - logging.getLogger("config_loader").warning( - "live 계좌 라우팅: 전략 '%s'의 계좌가 kis_api.accounts에 %s — " - "기본 계좌로 폴백합니다(다른 전략과 자본 풀 공유 가능).", - strategy, - "선언되지 않음" if strategy not in accounts else "빈 값", - ) + state = "선언되지 않음" if strategy not in accounts else "빈 값" + raise ValueError( + f"live 계좌 라우팅 차단: 전략 {strategy!r}의 계좌가 " + f"kis_api.accounts에 {state}. 기본 계좌 폴백은 허용되지 않습니다." + ) return kis.get("account_no", "") def with_strategy_overrides(self, strategy_name: str, overrides: dict) -> "ConfigOverlay": diff --git a/config/risk_params.yaml b/config/risk_params.yaml index 16dc4361..d1d37d03 100644 --- a/config/risk_params.yaml +++ b/config/risk_params.yaml @@ -20,7 +20,7 @@ position_sizing: signal_scaling: enabled: true min_scale: 0.5 # 최소 스케일 (약한 신호: 기본 수량의 50%) - max_scale: 1.5 # 최대 스케일 (강한 신호: 기본 수량의 150%) + max_scale: 1.0 # 최대 스케일 (강한 신호도 1회 손실 예산 1%를 초과하지 않음) score_range: [2, 5] # [최소점수, 최대점수] — 이 범위에서 선형 보간. 범위 밖은 클램핑 # --- 손절매 (Stop Loss) --- @@ -159,11 +159,19 @@ transaction_costs: commission_rate: 0.00015 # 편도 수수료 (0.015%) tax_rate: 0.0020 # 증권거래세+농특세: 매도 금액의 0.20% (2026년~ 코스피·코스닥 동일) # 국내 상장 ETF는 증권거래세 비과세 — 개별 주식 세율을 일괄 적용하면 ETF 전용 바스켓 - # (kr_pocket)의 매도 비용이 0.20%p 과대계상돼 승격 게이트의 비용 상한(연 1%) 판정까지 - # 왜곡된다. 여기 등록된 종목은 매도세 0으로 계산한다(수수료·슬리피지는 동일 적용). + # (kr_pocket)의 거래세가 과대계상된다. 여기 등록된 종목은 증권거래세만 0으로 계산한다. + # 국내주식형이 아닌 ETF의 보유기간 과세는 아래 holding_period_income_tax에서 별도 반영. tax_exempt_symbols: - "069500" # KODEX 200 - "357870" # TIGER CD금리투자KIS(합성) + # 기타 ETF 보유기간 과세: 법정 기준은 min(매매차익, 과표기준가격 증분) × 15.4%. + # 현재 과표기준가 시계열이 없으므로 등록 종목의 양(+) 매매차익 전액에 적용해 + # 세후 수익을 과대평가하지 않는 보수적 상한으로 모델링한다. + holding_period_income_tax: + enabled: true + rate: 0.154 + symbols: + - "357870" # 국내주식형이 아닌 CD금리 합성 ETF slippage: 0.0005 # 슬리피지 기본 비율 (0.05%). 동적 슬리피지 배수 적용 시 이 값 위에 곱해짐 slippage_ticks: 1 # 호가 단위 기준 슬리피지 틱 수 (지정가 주문 기준 1틱이 현실적) # 양도소득세 (대주주 해당 시에만 적용; 일반 투자자는 해당 없음) diff --git a/config/settings.yaml.example b/config/settings.yaml.example index 1a8b482a..6bb16a1f 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -8,12 +8,12 @@ kis_api: app_key: "" app_secret: "" - account_no: "00000000-00" # 기본 계좌 (단일 계좌 또는 미지정 전략용) - # 다중 계좌(전략별 분리): 전략A용/전략B용 계좌를 나누려면 accounts 에 전략명별 계좌번호 설정 - # 환경변수: KIS_ACCOUNT_NO_SCORING, KIS_ACCOUNT_NO_MEAN_REVERSION 등으로 덮어쓰기 가능 - # accounts: - # scoring: "11111111-01" - # mean_reversion: "22222222-02" + account_no: "00000000-00" # paper/호환 기본값. live 전략은 이 값으로 폴백하지 않음 + # live는 실행할 전략 키를 반드시 선언하고 번호는 .env로 주입합니다. + # 선언되지 않은 KIS_ACCOUNT_NO_* 환경변수는 무시됩니다. + accounts: + scoring: "" # .env: KIS_ACCOUNT_NO_SCORING=12345678-01 + # mean_reversion: "" # .env: KIS_ACCOUNT_NO_MEAN_REVERSION=12345678-02 base_url: "https://openapi.koreainvestment.com:9443" mock_url: "https://openapivts.koreainvestment.com:29443" use_mock: true @@ -62,6 +62,7 @@ trading: mode: "paper" auto_entry: false pending_order_ttl_seconds: 600 + ledger_reconcile_guard_ttl_seconds: 86400 # 불명확/부분 체결·체결 후 장부 실패 시 최소 1일 중복 차단 sync_broker_interval_minutes: 30 # live 모드에서 KIS 잔고와 DB 포지션 크로스체크 주기 (분) position_mismatch_auto_correct: false # true면 KIS↔DB 불일치 시 DB를 KIS 기준으로 자동 보정. false면 알림만 발송. position_mismatch_allow_empty_broker_delete: false # true면 KIS 보유 목록이 비어도 DB 포지션 자동 삭제 허용 @@ -101,7 +102,7 @@ email: # 웹 대시보드 (python main.py --mode dashboard) dashboard: - host: "127.0.0.1" # 기본은 로컬 전용. 외부 공개는 reverse proxy/인증 구성 후 명시적으로 0.0.0.0 사용 + host: "127.0.0.1" # 인증 없는 화면이므로 loopback 전용. reverse proxy도 이 주소로 연결 port: 8080 # 관심 종목(watchlist): 수작업 없이 시가총액·코스피200·모멘텀·저변동성 기준으로 자동 선정 가능 diff --git a/config/strategies.yaml b/config/strategies.yaml index 59db2486..8931572a 100644 --- a/config/strategies.yaml +++ b/config/strategies.yaml @@ -135,15 +135,6 @@ scoring: ma_golden_cross: 1 # 이동평균 골든크로스 (⚠️ 직관값, 미검증) ma_dead_cross: -1 # 이동평균 데드크로스 (⚠️ 직관값, 미검증) -# --- 추세 눌림목 전략 (Trend Pullback) --- -# 장기 상승추세(close > SMA200) 종목에서 단기 과매도(RSI < rsi_entry) 눌림에 진입. -# ADX > adx_min 으로 추세 존재 확인. 청산: close < SMA200 또는 RSI > rsi_exit. -# ATR trailing stop은 backtester 기존 설정(ATR 2.5) 사용. -trend_pullback: - rsi_entry: 35 # 진입: RSI 이 값 미만 (단기 과매도) - adx_min: 20 # 진입: ADX 이 값 초과 (추세 존재) - rsi_exit: 70 # 청산: RSI 이 값 초과 (과매수) - # --- 평균 회귀 전략 --- # 한계: 한국 시장에서 큰 폭 하락은 펀더멘털(실적·분식·대주주 등) 비중이 커서 평균 회귀가 성립하지 않는 종목이 많음. # Z-Score < -2 는 "기술적 과매도"와 "악화 기업"을 구분하지 못하며, ADX < adx_filter 도 하락 추세 구간에서 낮게 나와 필터가 불완전함. diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 85760945..66036a2e 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -394,7 +394,10 @@ def collect_basket_paper_evaluation( ) snaps = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == basket_key) + .filter( + PortfolioSnapshot.mode == "paper", + PortfolioSnapshot.account_key == basket_key, + ) .order_by(PortfolioSnapshot.date.asc()) .all() ) @@ -448,10 +451,13 @@ def _d(v): # 바꾼다 — 말기 입금이 전 기간 비용의 분모를 부풀려 게이트를 느슨하게 만들지 않게. # 흐름 0건이면 아래 두 값은 기존과 완전히 동일하다(하위 호환). from database.repositories import get_cash_flows, has_cash_flows - account_has_flows = has_cash_flows(account_key=basket_key) + account_has_flows = has_cash_flows(account_key=basket_key, mode="paper") if account_has_flows: initial_capital = time_weighted_capital( - initial_capital, get_cash_flows(account_key=basket_key), operation_start, today, + initial_capital, + get_cash_flows(account_key=basket_key, mode="paper"), + operation_start, + today, ) nav_return_pct = None diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 2e7ab8de..9d98fea0 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -20,6 +20,12 @@ _KST = ZoneInfo("Asia/Seoul") from config.config_loader import Config +from core.basket_risk import ( + RISK_EXIT_TAG, + basket_risk_levels, + evaluate_basket_stops, + symbols_in_reentry_cooldown, +) from core.portfolio_manager import PortfolioManager from core.data_collector import DataCollector from database.repositories import get_all_positions @@ -153,6 +159,10 @@ def _is_live(self) -> bool: """실전(live) 모드 여부.""" return str(self.config.trading.get("mode", "paper")).lower() == "live" + def _ledger_mode(self) -> str: + """schedule 등 비실전 런타임은 paper 장부로 정규화한다.""" + return "live" if self._is_live() else "paper" + def save_daily_nav_snapshot(self) -> bool: """바스켓 계정의 일일 NAV 스냅샷 저장. 트랙레코드 시계열의 1행. @@ -168,7 +178,10 @@ def save_daily_nav_snapshot(self) -> bool: try: snapshot = getattr(self, "_market_snapshot", None) or self._fetch_market_snapshot() prices = {s: v["price"] for s, v in snapshot.items()} - positions = get_all_positions(account_key=self.account_key) + positions = get_all_positions( + account_key=self.account_key, + mode=self._ledger_mode(), + ) missing = [p.symbol for p in positions if p.symbol not in prices] if missing: logger.warning( @@ -264,6 +277,7 @@ def get_current_weights(self, prices: dict[str, float] = None) -> dict[str, floa positions = get_all_positions( account_key=self.account_key if self.account_key else None, + mode=self._ledger_mode(), ) pos_map = {p.symbol: p for p in positions} @@ -370,6 +384,18 @@ def should_rebalance(self, prices: dict[str, float] = None) -> tuple[bool, str]: max_drift = max(abs(d["drift"]) for d in drifts.values()) if drifts else 0 if max_drift >= threshold: return True, f"최대 드리프트 {max_drift:.1%} >= 임계값 {threshold:.1%}" + # 종목별 드리프트가 전부 임계값 아래여도, 그 얇은 미달분의 합이 설계 배치율에서 + # 크게 벗어나 있으면 리밸런싱이 필요하다. 종목별 트리거만 보면 '현금이 새는' + # 상태를 영영 못 본다 — 매도는 min_trade를 넘겨 집행되는데 그 대금을 되돌리는 + # 매수는 9종목에 얇게 퍼져 전부 미달이라 집행되지 않기 때문이다(2026-08 실측: + # 배치율 61.0% → 54.9% 단조 감소, 19거래일간 재투자 0건). + gap = self._deployment_gap(prices) + band = float(self.rebalance_cfg.get("deployment_band", 0.03)) + if gap is not None and abs(gap) >= band: + return True, ( + f"집계 배치율 이탈 {gap:+.1%} (허용 밴드 ±{band:.1%}) — " + f"종목별 드리프트 {max_drift:.1%}는 임계값 미만" + ) return False, f"드리프트 {max_drift:.1%} < 임계값 {threshold:.1%}" elif trigger == "weekly": @@ -389,6 +415,63 @@ def should_rebalance(self, prices: dict[str, float] = None) -> tuple[bool, str]: return False, f"알 수 없는 트리거: {trigger}" + def _deployment_gap(self, prices: dict[str, float] = None) -> float | None: + """설계 대비 집계 배치율 격차. 음수면 미달(현금 과다), 양수면 초과. + + 총자산 대비 비율로 반환한다(예: -0.05 = 설계보다 5%p 덜 투자된 상태). + 계산 불가 시 None. + """ + try: + prices = prices or self._fetch_current_prices() + summary = self.portfolio_mgr.get_portfolio_summary(current_prices=prices) + total_value = float(summary.get("total_value", 0) or 0) + if total_value <= 0: + return None + positions = get_all_positions( + account_key=self.account_key, mode=self._ledger_mode(), + ) + stock_value = sum( + prices.get(p.symbol, p.avg_price) * p.quantity + for p in positions if p.symbol in self.holdings + ) + return stock_value / total_value - self._stock_fraction() + except Exception as exc: + logger.debug("배치율 격차 계산 실패: {}", exc) + return None + + # ------------------------------------------------------------------ + # 리스크 청산 (손절/익절/트레일링) + # ------------------------------------------------------------------ + + def plan_risk_exits(self, prices: dict[str, float] = None) -> list[RebalanceOrder]: + """바스켓 리스크 정책(baskets.yaml `risk:`)에 걸린 포지션의 청산 주문. + + 리밸런싱보다 먼저 실행돼야 한다 — 청산 대상을 그대로 둔 채 비중을 맞추면 + 손실 종목을 오히려 더 사게 된다. + + 정책이 없는 바스켓은 빈 리스트를 반환한다(순수 buy&hold — 기존 동작). + """ + prices = prices or self._fetch_current_prices() + positions = get_all_positions( + account_key=self.account_key, + mode=self._ledger_mode(), + ) + hits = evaluate_basket_stops(self.basket, positions, prices) + orders: list[RebalanceOrder] = [] + for hit in hits: + logger.warning( + "바스켓 '{}' 리스크 청산 대상 — {} {}: {}", + self.basket_name, hit["action"], hit["symbol"], hit["reason"], + ) + orders.append(RebalanceOrder( + symbol=hit["symbol"], action="SELL", quantity=hit["quantity"], + price=hit["price"], + # 사유에 RISK_EXIT/액션 표지를 남긴다 — 재진입 차단이 매매 이력에서 + # '리스크 청산으로 나간 종목'을 이 표지로 되찾는다. + reason=f"{RISK_EXIT_TAG} {hit['action']}: {hit['reason']}", + )) + return orders + # ------------------------------------------------------------------ # 리밸런싱 주문 계획 # ------------------------------------------------------------------ @@ -422,15 +505,29 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder positions = get_all_positions( account_key=self.account_key if self.account_key else None, + mode=self._ledger_mode(), ) pos_map = {p.symbol: p for p in positions} + # 손절/트레일링으로 방금 나간 종목은 매수 후보에서 뺀다. 이게 없으면 청산으로 + # 비워진 슬롯을 같은 사이클의 비중 교정이 곧바로 되사서 손실만 확정하는 + # 왕복매매가 된다(2026-08-07 10:07 실측: 현대차 -25% 손절 4초 뒤 재매수). + cooldown = symbols_in_reentry_cooldown( + self.basket, self.account_key, self._ledger_mode(), + ) + # 1) 후보 거래를 먼저 모두 계산(회전율 예산 적용 전). 거래액은 실제 주문 명목금액 기준. candidates: list[tuple[RebalanceOrder, float]] = [] for symbol in targets: target_w = targets[symbol] actual_w = actuals.get(symbol, 0.0) drift = target_w - actual_w + if drift > 0 and symbol in cooldown: + logger.info( + "종목 {} 매수 보류 — {} (비중 부족 {:.1%}는 차단 해제 후 교정)", + symbol, cooldown[symbol], drift, + ) + continue trade_value = abs(investable * target_w - investable * actual_w) if trade_value < min_trade: if drift > 0 and actual_w <= 0: @@ -483,6 +580,82 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder reason=f"비중 초과 ({actual_w:.1%} → {target_w:.1%}, {drift:.1%})", ), sell_qty * price)) + # 1-b) 집계 배치율 보충: 종목별 드리프트가 전부 min_trade 미만이라 개별로는 아무것도 + # 못 사는데, 그 얇은 미달분을 합치면 설계 배치율에서 크게 벗어나 있는 상태를 채운다. + # + # 이게 없으면 리밸런싱은 현금을 늘리기만 하는 한쪽 방향 래칫이 된다: 비중 초과 + # 종목은 min_trade를 넘겨 팔리는데(매도 대금 → 현금), 그 현금을 되돌리는 매수는 + # 9종목에 얇게 퍼져 전부 min_trade 미만이라 영원히 집행되지 않는다. + # 실측(2026-08-07~08-26): 8/07 리밸런싱이 현금을 +394,700원 늘린 뒤 19거래일간 + # 단 한 건도 재투자되지 않아 배치율이 61.0% → 54.9%로 단조 감소했고, 그 사이 + # KOSPI는 +8.17% 반등해 유휴 현금이 반등분의 45%를 깎아먹었다. + # + # 집행 규칙은 하나다: **그 매수가 집계 격차를 실제로 줄일 때만 산다.** + # 1주 단위 절사와 min_trade 때문에 딱 맞게 살 수 없으므로, '얼마를 넘기면 + # 안 된다'는 상한을 따로 두는 대신 매수 후 잔여 격차가 지금보다 작아지는지로 + # 판정한다 — 항상 목표에 가까워지고, 과다 매수는 자동으로 걸러진다. + stock_value = sum( + prices.get(p.symbol, p.avg_price) * p.quantity + for p in positions if p.symbol in targets + ) + shortfall = investable - stock_value + band = float(self.rebalance_cfg.get("deployment_band", 0.03)) * total_value + already = {o.symbol for o, _ in candidates} + if shortfall > band: + logger.info( + "바스켓 '{}' 집계 배치율 미달 {:,.0f}원 (실제 {:.1%} vs 설계 {:.1%}) — " + "격차를 줄이는 보충 매수만 집행", + self.basket_name, shortfall, + stock_value / total_value if total_value else 0, + self._stock_fraction(), + ) + remaining = shortfall + drift_limit = float(self.rebalance_cfg.get("drift_threshold", 0.05)) + topups: list[tuple[float, float, str, int, float]] = [] + for symbol, target_w in targets.items(): + if symbol in already or symbol in cooldown: + continue + price = prices.get(symbol, 0) + if price <= 0: + continue + if investable * (target_w - actuals.get(symbol, 0.0)) <= 0: + continue # 이미 목표 이상 보유 — 보충 대상 아님 + # min_trade를 넘기는 최소 수량(정수주). 이보다 적게 사면 집행되지 않는다. + qty = max(1, int(min_trade // price) + (1 if min_trade % price else 0)) + notional = qty * price + residual = abs(remaining - notional) + if residual >= abs(remaining): + continue # 격차를 줄이지 못함(과다 매수) — 건너뛴다 + # 집계 격차만 보고 사면 개별 종목이 자기 목표를 크게 넘어설 수 있다 + # (1주 단가가 총자산에 비해 클수록 심하다). 집계를 맞추자고 구성이 + # 무너지면 다음 사이클이 그걸 다시 팔아 왕복매매가 된다 — 종목 드리프트 + # 임계값 안에 들어오는 매수만 허용한다. 어느 종목도 통과 못 하면 주문 + # 없이 두고, 배치율 미달은 헬스 경보로 드러난다(자본 부족 신호). + projected_w = ( + (investable * actuals.get(symbol, 0.0) + notional) / investable + if investable > 0 else 1.0 + ) + if projected_w > target_w + drift_limit: + logger.debug( + "종목 {} 보충 보류: 매수 후 비중 {:.1%} > 목표 {:.1%} + 허용 {:.1%}", + symbol, projected_w, target_w, drift_limit, + ) + continue + topups.append((residual, notional, symbol, qty, price)) + # 잔여 격차를 가장 많이 줄이는 순서로 집행한다(1주 단가가 낮을수록 정밀). + topups.sort() + for _residual, notional, symbol, qty, price in topups: + if abs(remaining - notional) >= abs(remaining): + continue # 앞선 체결로 격차가 줄어 더는 개선이 아님 + candidates.append((RebalanceOrder( + symbol=symbol, action="BUY", quantity=qty, price=price, + reason=( + f"배치율 보충 (집계 {stock_value / total_value:.1%} → " + f"설계 {self._stock_fraction():.1%})" + ), + ), notional)) + remaining -= notional + # 2) SELL을 먼저(현금 확보) 두고 거래액 큰 순으로 정렬해 회전율 예산 우선권을 준다. # (기존엔 dict 순서대로라 BUY가 예산을 먼저 소진해 자금원 SELL이 누락될 수 있었다.) candidates.sort(key=lambda c: (0 if c[0].action == "SELL" else 1, -c[1])) @@ -602,6 +775,12 @@ def execute( reason=f"리밸런싱: {order.reason}", strategy=self.execution_strategy, avg_daily_volume=snapshot.get(order.symbol, {}).get("avg_volume"), + # 종목 구성과 종목별 상한은 baskets.yaml의 목표 비중표가 이미 + # 정한 분산 정책이다 — 그 위에 쌍별 상관 거부권을 또 얹으면 + # 하락장(대형주 상관 → 1)에 설계대로 채우는 주문이 전부 막힌다. + weight_policy_managed=True, + # 진입 레벨도 트랙 정책으로 기록한다(전역 단타 -3% 손절 금지). + risk_levels=basket_risk_levels(self.basket, order.price), ) else: res = executor.execute_sell( diff --git a/core/basket_risk.py b/core/basket_risk.py new file mode 100644 index 00000000..a0e992d5 --- /dev/null +++ b/core/basket_risk.py @@ -0,0 +1,255 @@ +"""바스켓 트랙별 리스크 정책 — 손절/익절 기준을 트랙 성격에 맞게 분리한다. + +배경(2026-08-07 점검): +전역 risk_params는 단타 신호 전략 기준이다 — 진입가 -3% 손절, +8% 익절, 고점 -5% +트레일링. 이 값이 저회전 buy&hold 바스켓 포지션에도 그대로 기록돼 왔는데, + + 1) 일일 리밸런싱 사이클은 손절/익절을 평가하지 않는다(평가는 core/scheduler.py의 + 장중 루프에만 있다). 그래서 9개 중 6개 포지션이 손절선을 뚫은 채 방치됐다. + 2) 그렇다고 그 -3% 손절을 그대로 켰다면 대형주 정상 변동에도 전 종목이 털려 + 하락을 손실로 확정했을 것이다. 애초에 이 트랙에 맞는 숫자가 아니다. + +즉 문제는 '손절이 꺼져 있다'가 아니라 '트랙에 맞지 않는 숫자가 장부에만 적혀 있다' +였다. 이 모듈은 바스켓별로 의도한 기준을 명시(baskets.yaml의 `risk:` 블록)하게 하고, +그 기준으로 진입 레벨을 기록하고 일일 사이클이 실제로 평가하게 만든다. + +baskets.yaml 예: + risk: + stop_loss_pct: 0.20 # 진입가 대비 -20%에서 청산 (0/미지정이면 손절 없음) + take_profit_pct: 0 # 0/미지정이면 익절 없음 (buy&hold 기본) + trailing_stop_pct: 0 # 0/미지정이면 트레일링 없음 +""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + + +def _pct(risk_cfg: dict[str, Any], key: str) -> float | None: + """비율 파라미터를 (0, 1) 범위로 읽는다. 미지정·0·범위 밖이면 None(장치 없음).""" + raw = (risk_cfg or {}).get(key) + if raw is None: + return None + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning("바스켓 리스크 설정 무시 — {}={!r}는 숫자가 아님", key, raw) + return None + if not (0 < value < 1): + if value != 0: + logger.warning( + "바스켓 리스크 설정 무시 — {}={}는 (0,1) 범위를 벗어남", key, value, + ) + return None + return value + + +# 리스크 청산 사유에 새겨 넣는 표지. 재진입 차단이 이 표지로 청산 이력을 찾는다. +RISK_EXIT_TAG = "RISK_EXIT" +_REENTRY_BLOCKING_ACTIONS = ("STOP_LOSS", "TRAILING_STOP") + + +def reentry_cooldown_days(basket_cfg: dict[str, Any]) -> int: + """손절/트레일링으로 청산한 종목의 재매수 차단 일수. 0이면 차단 없음. + + 이 값이 0이면 손절이 무의미해진다: 같은 사이클의 리밸런싱이 방금 비운 슬롯을 + '비중 0% → 목표 부족'으로 보고 즉시 되사기 때문이다. 실측 사고(2026-08-07 10:07): + 현대차를 -25% 손절로 394,500원에 판 4초 뒤 395,500원에 되샀다 — 손실 -207,349원만 + 확정하고 노출·비중은 그대로인 왕복매매였다. + """ + raw = ((basket_cfg or {}).get("risk", {}) or {}).get("reentry_cooldown_days") + if raw is None: + return 0 + try: + days = int(raw) + except (TypeError, ValueError): + logger.warning("reentry_cooldown_days={!r}는 정수가 아님 — 차단 없음으로 처리", raw) + return 0 + return max(0, days) + + +def symbols_in_reentry_cooldown( + basket_cfg: dict[str, Any], + account_key: str, + mode: str, + now=None, +) -> dict[str, str]: + """재매수가 차단된 종목 → 사유 문자열. + + 최근 reentry_cooldown_days 안에 손절/트레일링으로 청산된 종목을 반환한다. + 조회 실패 시 빈 dict(차단 없음)로 폴백한다 — 관측 실패가 리밸런싱 전체를 + 막지는 않게 하되, 경고는 남긴다. + """ + days = reentry_cooldown_days(basket_cfg) + if days <= 0: + return {} + + from datetime import datetime, timedelta + + from database.repositories import get_trade_history + + current = now or datetime.now() + since = current - timedelta(days=days) + try: + trades = get_trade_history( + mode=mode, start_date=since, account_key=account_key, + ) + except Exception as exc: + logger.warning("재진입 차단 조회 실패 — 차단 없이 진행: {}", exc) + return {} + + blocked: dict[str, str] = {} + for t in trades: + if str(getattr(t, "action", "")).upper() != "SELL": + continue + reason = str(getattr(t, "reason", "") or "") + if not any(tag in reason for tag in _REENTRY_BLOCKING_ACTIONS): + continue + symbol = str(getattr(t, "symbol", "") or "") + if not symbol or symbol in blocked: + continue + executed_at = getattr(t, "executed_at", None) + until = ( + (executed_at + timedelta(days=days)).strftime("%Y-%m-%d") + if executed_at else "?" + ) + blocked[symbol] = f"{days}일 재진입 차단 (~{until}) — 직전 리스크 청산" + return blocked + + +def basket_risk_config(basket_cfg: dict[str, Any]) -> dict[str, float | None]: + """바스켓의 리스크 정책을 정규화해 반환한다. + + 반환: {"stop_loss_pct", "take_profit_pct", "trailing_stop_pct"} — 각 값은 + 비율(float) 또는 None(해당 장치 없음). + """ + risk_cfg = (basket_cfg or {}).get("risk", {}) or {} + return { + "stop_loss_pct": _pct(risk_cfg, "stop_loss_pct"), + "take_profit_pct": _pct(risk_cfg, "take_profit_pct"), + "trailing_stop_pct": _pct(risk_cfg, "trailing_stop_pct"), + } + + +def has_risk_policy(basket_cfg: dict[str, Any]) -> bool: + """바스켓이 `risk:` 블록을 명시했는지 여부. + + 값이 전부 0이어도 True다 — '손절 없음'은 누락이 아니라 결정이며, 그 결정과 + '아무 말도 안 했으니 전역 단타 기본값(-3%)을 쓴다'는 정반대 결과를 낳는다. + """ + return isinstance((basket_cfg or {}).get("risk"), dict) + + +def basket_risk_levels( + basket_cfg: dict[str, Any], entry_price: float, +) -> dict[str, float | None] | None: + """진입가 기준 손절/익절/트레일링 가격. `risk:` 블록이 없으면 None. + + None을 반환하면 호출부는 '이 바스켓은 리스크 레벨을 지정하지 않음'으로 보고 + 전역 risk_params 기본값을 쓴다(기존 동작 유지). 블록이 있으면 dict를 반환하며, + 그 안의 None은 '그 장치는 이 트랙에 없음'을 뜻한다. + """ + if not has_risk_policy(basket_cfg): + return None + cfg = basket_risk_config(basket_cfg) + + # 진입가가 유효하지 않으면 레벨을 계산할 수 없다. 이때 None을 반환해 전역 + # 기본값으로 되돌아가면 정책이 '손절 없음'인 트랙에 단타 손절이 다시 적힌다 — + # 정책은 유지한 채 레벨만 비운다(기록 없음). + try: + entry = float(entry_price) + except (TypeError, ValueError): + entry = 0.0 + if not entry > 0: + return { + "stop_loss_price": None, + "take_profit_price": None, + "trailing_stop_price": None, + } + + def _level(pct: float | None, direction: int) -> float | None: + if pct is None: + return None + return round(entry * (1 + direction * pct), 0) + + return { + "stop_loss_price": _level(cfg["stop_loss_pct"], -1), + "take_profit_price": _level(cfg["take_profit_pct"], +1), + "trailing_stop_price": _level(cfg["trailing_stop_pct"], -1), + } + + +def evaluate_basket_stops( + basket_cfg: dict[str, Any], + positions: list[Any], + prices: dict[str, float], +) -> list[dict[str, Any]]: + """보유 포지션을 바스켓 정책에 비춰 평가하고 청산 대상을 반환한다. + + 포지션에 저장된 stop_loss_price 컬럼을 읽지 않고 avg_price와 정책 비율로 매번 + 계산한다 — 정책이 단일 진실이어야 과거에 다른 기준으로 기록된 값(전역 -3% 등)이 + 남아 있어도 판단이 오염되지 않는다. + + 트레일링은 포지션의 highest_price(고점)를 기준으로 한다. 고점 정보가 없으면 + 진입가를 고점으로 보아 손절과 같아지므로, 그 경우 트레일링은 건너뛴다. + + 반환: [{symbol, action, price, quantity, avg_price, level, reason}] — action은 + "STOP_LOSS" | "TAKE_PROFIT" | "TRAILING_STOP". + """ + cfg = basket_risk_config(basket_cfg) + if not any(v is not None for v in cfg.values()): + return [] + + hits: list[dict[str, Any]] = [] + for pos in positions or []: + symbol = str(getattr(pos, "symbol", "") or "") + price = prices.get(symbol) + avg_price = float(getattr(pos, "avg_price", 0) or 0) + quantity = int(getattr(pos, "quantity", 0) or 0) + if not symbol or not price or price <= 0 or avg_price <= 0 or quantity <= 0: + continue + + # 익절 → 트레일링 → 손절 순. 같은 가격이 여러 조건에 걸리면 이익 실현을 + # 우선한다(core/order_executor.check_stop_loss_take_profit와 같은 순서). + tp_pct = cfg["take_profit_pct"] + if tp_pct is not None: + level = avg_price * (1 + tp_pct) + if price >= level: + hits.append({ + "symbol": symbol, "action": "TAKE_PROFIT", "price": float(price), + "quantity": quantity, "avg_price": avg_price, "level": round(level, 0), + "reason": f"익절 도달: 현재가 {price:,.0f} ≥ 목표가 {level:,.0f} (+{tp_pct:.0%})", + }) + continue + + ts_pct = cfg["trailing_stop_pct"] + highest = float(getattr(pos, "highest_price", 0) or 0) + if ts_pct is not None and highest > avg_price: + level = highest * (1 - ts_pct) + if price <= level: + hits.append({ + "symbol": symbol, "action": "TRAILING_STOP", "price": float(price), + "quantity": quantity, "avg_price": avg_price, "level": round(level, 0), + "reason": ( + f"트레일링 스탑: 현재가 {price:,.0f} ≤ 고점 {highest:,.0f} " + f"대비 -{ts_pct:.0%} ({level:,.0f})" + ), + }) + continue + + sl_pct = cfg["stop_loss_pct"] + if sl_pct is not None: + level = avg_price * (1 - sl_pct) + if price <= level: + hits.append({ + "symbol": symbol, "action": "STOP_LOSS", "price": float(price), + "quantity": quantity, "avg_price": avg_price, "level": round(level, 0), + "reason": ( + f"손절: 현재가 {price:,.0f} ≤ 손절선 {level:,.0f} " + f"(진입가 {avg_price:,.0f} -{sl_pct:.0%})" + ), + }) + + return hits diff --git a/core/blackswan_detector.py b/core/blackswan_detector.py index 1594de57..dc9c37da 100644 --- a/core/blackswan_detector.py +++ b/core/blackswan_detector.py @@ -35,28 +35,66 @@ class BlackSwanDetector: def __init__(self, config: Config = None): self.config = config or Config.get() - # 블랙스완 감지 임계값 - self.single_stock_threshold = -0.05 # 개별 종목 -5% - self.portfolio_threshold = -0.03 # 포트폴리오 -3% - self.consecutive_days = 3 # 연속 하락 일수 - self.consecutive_threshold = -0.02 # 연속 하락 기준 -2% + risk_params = ( + self.config.risk_params + if hasattr(self.config, "risk_params") + else self.config.get("risk_params", {}) + ) or {} + blackswan_cfg = risk_params.get("blackswan", {}) or {} + trading = ( + self.config.trading + if hasattr(self.config, "trading") + else self.config.get("trading", {}) + ) or {} + + # risk_params.yaml이 운영과 백테스트의 단일 임계값 소스다. + self.single_stock_threshold = float( + blackswan_cfg.get("single_stock_threshold", -0.05) + ) + self.portfolio_threshold = float( + blackswan_cfg.get("portfolio_threshold", -0.03) + ) + self.consecutive_days = int(blackswan_cfg.get("consecutive_days", 3)) + self.consecutive_threshold = float( + blackswan_cfg.get("consecutive_threshold", -0.02) + ) # 쿨다운 관리 - self.cooldown_minutes = 60 # 기본 1시간 매매 중단 + self.cooldown_minutes = int(blackswan_cfg.get("cooldown_minutes", 60)) self._cooldown_until = None # 매매 재개 시각 self._triggered_count = 0 # 발동 횟수 self._triggered_date = None # 발동 일자 (일 단위 리셋용) # 쿨다운 해제 후 recovery 관리 - trading = self.config.trading if hasattr(self.config, "trading") else self.config.get("trading", {}) - self.recovery_minutes = int(trading.get("blackswan_recovery_minutes", 120)) - self.recovery_scale = float(trading.get("blackswan_recovery_scale", 0.5)) + # 기존 settings.yaml 키는 risk_params 키가 없을 때만 하위 호환 폴백. + self.recovery_minutes = int( + blackswan_cfg.get( + "recovery_minutes", + trading.get("blackswan_recovery_minutes", 120), + ) + ) + self.recovery_scale = float( + blackswan_cfg.get( + "recovery_scale", + trading.get("blackswan_recovery_scale", 0.5), + ) + ) self._recovery_until = None self._cooldown_just_ended = False self._daily_returns: list[float] = [] - logger.info("BlackSwanDetector 초기화 완료") + logger.info( + "BlackSwanDetector 초기화 완료 " + "(stock={:.1f}%, portfolio={:.1f}%, consecutive={}x{:.1f}%, cooldown={}m, recovery={}m@{:.0f}%)", + self.single_stock_threshold * 100, + self.portfolio_threshold * 100, + self.consecutive_days, + self.consecutive_threshold * 100, + self.cooldown_minutes, + self.recovery_minutes, + self.recovery_scale * 100, + ) def check_stock(self, symbol: str, current_price: float, prev_close: float) -> dict: """ diff --git a/core/cycle_observability.py b/core/cycle_observability.py index c15778e7..6af1a7cc 100644 --- a/core/cycle_observability.py +++ b/core/cycle_observability.py @@ -63,6 +63,47 @@ def format_gap_alert(basket_name: str, gaps: list[Any], *, today: Any = None) -> return f"{head}: {shown}{more}{tail}" +def unreported_snapshot_gaps( + account_key: str, + gaps: list, + mode: str = "paper", +) -> list: + """이미 경보를 남긴 결측일을 걸러 아직 알리지 않은 것만 반환한다. + + 복구 불가능한 과거 결측은 매 사이클 다시 감지되므로, 거르지 않으면 같은 하루가 + 영원히 매일 경보를 울린다 — 실측(2026-08-26 점검): 8/18 결측 하나가 3주간 16건의 + warning을 만들어 다른 신호를 덮었다. 경보의 가치는 '새로운 사실'에 있으므로 결측일 + 기준으로 1회만 알린다(승격 게이트의 커버리지 집계는 별개로 항상 전체를 본다). + + 조회 실패 시에는 걸러내지 않고 원본을 그대로 돌려준다 — 관측 실패로 경보가 조용히 + 사라지는 것보다 중복이 낫다. + """ + if not gaps: + return [] + try: + from database.models import OperationEvent, get_session + + session = get_session() + try: + rows = ( + session.query(OperationEvent.message) + .filter( + OperationEvent.event_type == "SNAPSHOT_GAP", + OperationEvent.strategy == account_key, + OperationEvent.mode == mode, + ) + .all() + ) + finally: + session.close() + reported = " ".join(str(r[0] or "") for r in rows) + except Exception as exc: # pragma: no cover - 관측 실패는 경보를 막지 않는다 + logger.debug("결측 경보 중복 판정 실패 — 원본 그대로 사용: {}", exc) + return list(gaps) + + return [g for g in gaps if _as_date(g).isoformat() not in reported] + + def detect_snapshot_gaps_for_account( config: Any, account_key: str, @@ -87,12 +128,21 @@ def detect_snapshot_gaps_for_account( today_d = _as_date(today) th = TradingHours(config) + ledger_mode = ( + "live" + if str(getattr(config, "trading", {}).get("mode", "paper")).lower() + == "live" + else "paper" + ) session = get_session() try: snaps = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == account_key) + .filter( + PortfolioSnapshot.mode == ledger_mode, + PortfolioSnapshot.account_key == account_key, + ) .all() ) snap_dates = [_as_date(s.date) for s in snaps] diff --git a/core/live_gate.py b/core/live_gate.py index 6a36f9a5..2ceb6a6c 100644 --- a/core/live_gate.py +++ b/core/live_gate.py @@ -123,6 +123,24 @@ def get_current_git_hash() -> str: return "unknown" +def get_current_git_worktree_state() -> tuple[bool | None, str]: + """Return ``(is_clean, detail)`` for tracked and untracked changes. + + ``is_clean`` is ``None`` when git cannot provide a trustworthy answer. A + caller must treat that state as unsafe rather than assuming the worktree is + clean. + """ + try: + output = subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=normal"], + stderr=subprocess.DEVNULL, + text=True, + ).rstrip("\r\n") + except Exception as exc: + return None, str(exc) + return not bool(output), output + + def _read_json(path: Path) -> tuple[dict[str, Any] | None, str | None]: try: data = json.loads(path.read_text(encoding="utf-8")) @@ -934,6 +952,7 @@ def validate_live_readiness( current_blockers_path: str | Path | None = None, now: datetime | None = None, current_git_hash: str | None = None, + current_git_worktree_state: tuple[bool | None, str] | None = None, max_artifact_age_days: int = LIVE_GATE_MAX_ARTIFACT_AGE_DAYS, ) -> list[str]: """Validate the canonical evidence chain required before live trading.""" @@ -947,6 +966,11 @@ def validate_live_readiness( ) now = now or datetime.now() current_git_hash = current_git_hash or get_current_git_hash() + current_git_worktree_state = ( + current_git_worktree_state + if current_git_worktree_state is not None + else get_current_git_worktree_state() + ) missing = [name for name in REQUIRED_PROMOTION_ARTIFACTS if not (promotion_base / name).exists()] if missing: @@ -1014,6 +1038,20 @@ def validate_live_readiness( "현재 코드로 canonical 평가를 다시 실행하세요." ) + worktree_clean, worktree_detail = current_git_worktree_state + if worktree_clean is None: + issues.append( + "현재 git worktree 상태 확인 실패. tracked/untracked 변경 여부를 검증할 수 없어 " + "live 전환 불가." + ) + elif not worktree_clean: + changed_entries = len(worktree_detail.splitlines()) if worktree_detail else 1 + issues.append( + "현재 git worktree가 clean 상태가 아님 " + f"(tracked/untracked 변경 {changed_entries}건). 변경을 검토하고 clean commit에서 " + "canonical 평가를 다시 실행하세요." + ) + yaml_hash = _config_hash(config, "yaml_hash") resolved_hash = _config_hash(config, "resolved_hash") if metadata.get("config_yaml_hash") != yaml_hash: diff --git a/core/operator_health.py b/core/operator_health.py index c5e0bdb8..7cb0c2bb 100644 --- a/core/operator_health.py +++ b/core/operator_health.py @@ -199,6 +199,65 @@ def summarize_deployment( } +def summarize_contribution_plan( + basket_name: str, + plan_cfg: dict[str, Any] | None, + last_flow_date: Any, + track_start_date: Any, + today: Any, + grace_days: int = 7, +) -> dict[str, Any]: + """적립 계획(contribution_plan)이 실제로 이행되고 있는지 판정한다. 순수 함수. + + 적립식 트랙의 수익 엔진은 '매월 넣는 돈' 그 자체다 — 적립이 멈추면 잔고가 1주 + 단위를 못 넘겨 배치율이 영영 수렴하지 않고, 트랙은 표면상 정상(스냅샷 저장됨, + 주문 실패 0건)으로 보이면서 설계대로 굴러가지 않는다. 실측(2026-08-26 점검): + kr_pocket의 cash_flows가 8월 내내 비어 있었는데 헬스는 26일간 ✅ OK만 반환했다. + + 반환: {"verdict": "OK"|"ATTENTION", "note": str, "days_since": int|None} + """ + def _d(v: Any): + return v.date() if hasattr(v, "date") and callable(getattr(v, "date")) else v + + cfg = plan_cfg or {} + if cfg.get("enabled") is not True: + return {"verdict": "OK", "note": "", "days_since": None} + + today_d = _d(today) + cadence = str(cfg.get("cadence", "monthly")).lower() + period_days = {"weekly": 7, "monthly": 31, "quarterly": 92}.get(cadence, 31) + amount = cfg.get("amount") + amount_txt = f"{float(amount):,.0f}원 " if amount is not None else "" + + if last_flow_date is None: + start = _d(track_start_date) if track_start_date is not None else None + if start is None: + return {"verdict": "OK", "note": "", "days_since": None} + elapsed = (today_d - start).days + if elapsed <= period_days + grace_days: + return {"verdict": "OK", "note": "", "days_since": elapsed} + return { + "verdict": "ATTENTION", + "note": ( + f"'{basket_name}' {cadence} 적립 {amount_txt}미실행 — 트랙 개시 {elapsed}일 " + f"동안 입금 기록 0건. 적립이 이 트랙의 수익 엔진이므로 배치율이 수렴하지 않는다" + ), + "days_since": elapsed, + } + + days_since = (today_d - _d(last_flow_date)).days + if days_since <= period_days + grace_days: + return {"verdict": "OK", "note": "", "days_since": days_since} + return { + "verdict": "ATTENTION", + "note": ( + f"'{basket_name}' {cadence} 적립 {amount_txt}지연 {days_since}일 " + f"(마지막 입금 {_d(last_flow_date)})" + ), + "days_since": days_since, + } + + def summarize_basket_operation( enabled_baskets: list[str], last_snapshot_date: Any, @@ -208,6 +267,7 @@ def summarize_basket_operation( deployment_ratio: float | None = None, design_fraction: float | None = None, deployment_tolerance: float = 0.05, + contribution_notes: list[str] | None = None, ) -> dict[str, Any]: """바스켓 paper 운영(트랙레코드 축적) 상태를 verdict + 요약으로 환원한다. @@ -263,6 +323,11 @@ def _as_date(v: Any): if dep["note"]: notes.append(dep["note"]) + # 적립 미실행 — 적립식 트랙에서는 '주문 실패 0건'과 무관하게 설계가 안 돌아가는 상태다. + for note in contribution_notes or []: + verdict = "ATTENTION" + notes.append(note) + return { "verdict": verdict, "enabled_baskets": list(enabled_baskets), diff --git a/core/order_executor.py b/core/order_executor.py index 9eb2758d..c800bfbb 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -9,6 +9,7 @@ """ import math +import os import time as time_mod from datetime import datetime, timedelta from loguru import logger @@ -20,6 +21,7 @@ save_trade, save_position, delete_position, reduce_position, delete_trade_by_id, get_position, get_all_positions, save_failed_order, count_monthly_buy_trades, save_order_record, get_open_order_records, reconcile_order_record, + get_trading_halt_state, ) from monitoring.logger import log_trade from core.order_guard import OrderGuard @@ -51,6 +53,8 @@ class OrderExecutor: """ MAX_RETRIES = 3 # 주문 재시도 최대 횟수 + EMERGENCY_CANCEL_VERIFY_ATTEMPTS = 3 + EMERGENCY_CANCEL_VERIFY_DELAY_SECONDS = 0.2 # risk_params.slippage 기본 0.05% 대비 3배 초과 시 warning, 1% 초과 시 디스코드 SLIPPAGE_WARN_PCT = 0.15 SLIPPAGE_DISCORD_PCT = 1.0 @@ -94,18 +98,181 @@ def _live_buy_gate_check(self, action: str = "BUY") -> dict: """live 신규 BUY는 canonical live gate 통과 경로에서만 허용한다.""" if self.mode != "live" or str(action).upper() != "BUY": return {"allowed": True, "reason": ""} - if self.live_gate_validated: + # 시작 시 확인만으로 끝내면 장기 실행 중 환경 kill switch를 내려도 다음 + # 주문이 계속 나갈 수 있다. 실제 돈이 움직이는 경계에서 매번 재확인한다. + live_switch_enabled = str(os.getenv("ENABLE_LIVE_TRADING", "")).strip().lower() in { + "1", + "true", + "yes", + "on", + } + if not self.live_gate_validated: + reason = ( + "live BUY는 run_live_trading/live rebalance의 readiness gate를 " + "통과한 OrderExecutor에서만 실행할 수 있습니다." + ) + elif self._is_real_money_live() and not live_switch_enabled: + reason = ( + "실계좌 신규 BUY kill switch가 비활성입니다: " + "ENABLE_LIVE_TRADING=true가 주문 시점에도 필요합니다." + ) + else: + profile = self._real_money_live_risk_profile_check() + if profile["allowed"]: + return {"allowed": True, "reason": ""} + reason = profile["reason"] + logger.error("실전 신규 매수 차단: {}", reason) + return { + "allowed": False, + "reason": reason, + "live_gate_blocked": True, + "mode": self.mode, + } + + def _real_money_live_risk_profile_check(self) -> dict: + """실계좌에서 필수 손실 제한이 누락·완화되면 주문 시점에 차단한다.""" + if not self._is_real_money_live(): + return {"allowed": True, "reason": ""} + + risk = self.config.risk_params or {} + ps = risk.get("position_sizing") or {} + dd = risk.get("drawdown") or {} + div = risk.get("diversification") or {} + liquidity = risk.get("liquidity_filter") or {} + correlation = div.get("correlation_risk") or {} + gap = risk.get("gap_risk") or {} + issues: list[str] = [] + + def _ratio( + label: str, + value, + *, + maximum: float | None = None, + minimum: float = 0.0, + ) -> None: + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + issues.append(f"{label}=누락/비숫자") + return + if ( + not math.isfinite(number) + or number <= minimum + or maximum is not None and number > maximum + ): + upper = f"≤{maximum:.0%}" if maximum is not None else "" + issues.append(f"{label}={value!r}(필수 >{minimum:.0%}{upper})") + + _ratio("max_risk_per_trade", ps.get("max_risk_per_trade"), maximum=0.02) + _ratio("max_portfolio_mdd", dd.get("max_portfolio_mdd"), maximum=0.20) + _ratio("max_daily_loss", dd.get("max_daily_loss"), maximum=0.05) + _ratio("max_position_ratio", div.get("max_position_ratio"), maximum=0.25) + _ratio("max_investment_ratio", div.get("max_investment_ratio"), maximum=0.85) + _ratio("min_cash_ratio", div.get("min_cash_ratio"), minimum=0.05) + try: + if ( + float(div.get("max_investment_ratio")) + + float(div.get("min_cash_ratio")) + > 1.0 + ): + issues.append("max_investment_ratio + min_cash_ratio는 100% 이하여야 함") + except (TypeError, ValueError, OverflowError): + pass + + try: + max_positions = int(div.get("max_positions")) + if max_positions <= 0 or max_positions > 20: + raise ValueError + except (TypeError, ValueError, OverflowError): + issues.append("max_positions는 1~20 정수여야 함") + + required_true = ( + ("liquidity_filter.enabled", liquidity.get("enabled")), + ("liquidity_filter.check_on_entry", liquidity.get("check_on_entry")), + ("liquidity_filter.strict", liquidity.get("strict")), + ("correlation_risk.enabled", correlation.get("enabled")), + ("correlation_risk.strict", correlation.get("strict")), + ("diversification.sector_map_strict", div.get("sector_map_strict")), + ("gap_risk.enabled", gap.get("enabled")), + ) + issues.extend(label for label, enabled in required_true if enabled is not True) + + if issues: + return { + "allowed": False, + "reason": "실계좌 리스크 프로필 검증 실패: " + "; ".join(issues), + "live_risk_profile_blocked": True, + } + return {"allowed": True, "reason": ""} + + def _global_trading_halt_check( + self, + action: str = "BUY", + *, + symbol: str = "", + strategy: str = "", + ) -> dict: + """영속 전역 HALT와 상태 조회 실패에서 모든 BUY를 fail-closed한다. + + HALT는 청산을 막으면 안 되므로 SELL은 DB 조회 자체를 하지 않고 + 즉시 통과시킨다. + """ + if str(action).upper() != "BUY": return {"allowed": True, "reason": ""} + try: + state = get_trading_halt_state() + except Exception as exc: + reason = ( + "전역 거래 HALT 상태를 확인하지 못해 신규 BUY를 " + f"fail-closed 차단합니다: {exc}" + ) + logger.exception("신규 BUY 차단: {}", reason) + _log_op_event( + "TRADING_HALT_CHECK_FAILED", + reason, + severity="critical", + symbol=symbol or None, + strategy=strategy or None, + detail={"fail_closed": True, "error": str(exc)}, + mode=self.mode, + ) + return { + "allowed": False, + "reason": reason, + "trading_halt_blocked": True, + "trading_halt_check_failed": True, + "mode": self.mode, + } + + if not state.get("halted", False): + return {"allowed": True, "reason": "", "trading_halt_state": state} + + halt_reason = str(state.get("reason") or "운영자 전역 HALT") reason = ( - "live BUY는 run_live_trading/live rebalance의 readiness gate를 " - "통과한 OrderExecutor에서만 실행할 수 있습니다." + f"전역 거래 HALT 활성 중이므로 신규 BUY를 차단합니다: " + f"{halt_reason} (event_id={state.get('event_id')})" + ) + logger.critical("신규 BUY 차단: {}", reason) + _log_op_event( + "TRADING_HALT_BUY_BLOCKED", + reason, + severity="critical", + symbol=symbol or None, + strategy=strategy or None, + detail={ + "halt_event_id": state.get("event_id"), + "halt_reason": halt_reason, + "halt_source": state.get("source"), + }, + mode=self.mode, ) - logger.error("실전 신규 매수 차단: {}", reason) return { "allowed": False, "reason": reason, - "live_gate_blocked": True, + "trading_halt_blocked": True, + "trading_halt_check_failed": False, + "trading_halt_state": state, "mode": self.mode, } @@ -163,6 +330,477 @@ def _persistent_live_order_block(self, symbol: str, order) -> dict | None: "open_order_records": open_records, } + def _claim_live_order_guard(self, symbol: str, order, ttl_seconds: int) -> dict | None: + """브로커 호출 직전 동일 종목 주문권을 원자적으로 획득한다.""" + try: + claimed = OrderGuard.mark_pending(symbol, ttl_seconds=ttl_seconds) + except Exception as exc: + order.transition(OrderStatus.REJECTED, reason="OrderGuard DB claim 실패") + self._persist_order_record(order) + logger.exception( + "OrderGuard 원자적 획득 실패 — 실전 주문 차단: {} — {}", + symbol, + exc, + ) + return { + "success": False, + "reason": ( + "중복 주문 가드 상태를 안전하게 기록하지 못해 " + "실전 주문을 보류했습니다." + ), + "symbol": symbol, + "mode": self.mode, + "order_guard_blocked": True, + "order_guard_check_failed": True, + } + + if claimed: + return None + + order.transition(OrderStatus.REJECTED, reason="OrderGuard pending") + self._persist_order_record(order) + return { + "success": False, + "reason": f"{symbol} 종목에 미체결/최근 주문이 남아 있어 중복 주문을 차단했습니다.", + "symbol": symbol, + "mode": self.mode, + "order_guard_blocked": True, + "order_guard_check_failed": False, + } + + def _safe_order_guard_ttl( + self, + key: str, + *, + default: int, + minimum: int, + ) -> int: + """잘못된 TTL 설정이 중복 주문 가드를 즉시 만료시키지 않게 한다.""" + raw = self.config.trading.get(key, default) + try: + if isinstance(raw, bool): + raise ValueError("boolean TTL") + ttl = int(raw) + except (TypeError, ValueError, OverflowError): + ttl = default + logger.critical( + "잘못된 {}={} — 안전 기본값 {}초 사용", + key, + raw, + default, + ) + if ttl < minimum: + logger.critical( + "너무 짧은 {}={}초 — 안전 기본값 {}초 사용 (최소 {}초)", + key, + ttl, + default, + minimum, + ) + return default + return ttl + + def _halt_after_post_fill_ledger_failure( + self, + *, + order, + action: str, + error: Exception, + ) -> None: + """브로커 체결 뒤 로컬 장부 반영 실패를 전역 BUY 중단으로 승격한다.""" + if self.mode != "live": + return + + ttl_seconds = self._safe_order_guard_ttl( + "ledger_reconcile_guard_ttl_seconds", + default=86_400, + minimum=3_600, + ) + try: + OrderGuard.extend_pending(order.symbol, ttl_seconds=ttl_seconds) + except Exception as guard_exc: + logger.exception( + "체결 후 원장 실패 가드 연장 실패: {} — {}", + order.symbol, + guard_exc, + ) + + reason = ( + f"실전 {action} 체결 후 로컬 원장 반영 실패 — " + f"브로커 잔고 재조정 필요: {order.symbol} order_id={order.order_id}" + ) + try: + from database.repositories import set_trading_halt + + set_trading_halt( + reason, + source="core.order_executor.post_fill_ledger", + mode="live", + detail={ + "symbol": order.symbol, + "action": action, + "order_id": order.order_id, + "broker_order_id": order.broker_order_id, + "order_status": order.status.value, + "error": str(error), + "requires_broker_reconcile": True, + "guard_ttl_seconds": ttl_seconds, + }, + ) + except Exception as halt_exc: + logger.exception( + "체결 후 원장 실패 global HALT 영속화 실패: {}", + halt_exc, + ) + + logger.critical("{} — error={}", reason, error) + try: + from core.notifier import Notifier + + Notifier(self.config).send_message( + f"🚨 {reason}\n오류: {error}", + critical=True, + ) + except Exception as notify_exc: + logger.error("체결 후 원장 실패 알림 전송 실패: {}", notify_exc) + + def _halt_for_uncertain_live_execution( + self, + *, + order, + action: str, + reason: str, + execution: dict | None = None, + ) -> None: + """실계좌 노출이 불명확하면 신규 BUY를 영속 차단한다.""" + if not self._is_real_money_live(): + return + + ttl_seconds = self._safe_order_guard_ttl( + "ledger_reconcile_guard_ttl_seconds", + default=86_400, + minimum=3_600, + ) + try: + OrderGuard.extend_pending(order.symbol, ttl_seconds=ttl_seconds) + except Exception as exc: + logger.exception("불명확 체결 OrderGuard 연장 실패: {}", exc) + detail = { + "symbol": order.symbol, + "action": action, + "order_id": order.order_id, + "broker_order_id": order.broker_order_id, + "order_status": order.status.value, + "execution_reason": reason, + "requires_broker_reconcile": True, + "guard_ttl_seconds": ttl_seconds, + } + if execution: + detail["execution"] = execution + halt_reason = ( + f"실전 {action} 주문 체결/잔량 불명확 — 브로커 대조 전 신규 BUY 중단: " + f"{order.symbol} order_id={order.order_id} ({reason})" + ) + try: + from database.repositories import set_trading_halt + + set_trading_halt( + halt_reason, + source="core.order_executor.uncertain_execution", + mode="live", + detail=detail, + ) + except Exception as exc: + logger.exception("불명확 체결 global HALT 영속화 실패: {}", exc) + logger.critical(halt_reason) + try: + from core.notifier import Notifier + + Notifier(self.config).send_message(f"🚨 {halt_reason}", critical=True) + except Exception as exc: + logger.error("불명확 체결 알림 전송 실패: {}", exc) + + def _emergency_cancel_block( + self, + *, + order, + reason: str, + cancel_status: dict | None = None, + cancellation_attempts: list[dict] | None = None, + ) -> dict: + """긴급 매도 전 충돌 BUY 취소가 불명확하면 매도를 중단하고 HALT한다.""" + persist_error = "" + try: + order.transition(OrderStatus.REJECTED, reason=reason) + self._persist_order_record(order) + except Exception as exc: + persist_error = str(exc) + logger.exception( + "긴급 취소 실패 SELL 주문 상태 저장 실패 — HALT는 계속 진행: {}", + exc, + ) + detail = { + "reason": reason, + "cancel_status": cancel_status or {}, + "cancellation_attempts": cancellation_attempts or [], + } + if persist_error: + detail["sell_order_persist_error"] = persist_error + self._halt_for_uncertain_live_execution( + order=order, + action="CANCEL_BEFORE_EMERGENCY_SELL", + reason="emergency_buy_cancel_unconfirmed", + execution=detail, + ) + return { + "success": False, + "reason": reason, + "symbol": order.symbol, + "mode": self.mode, + "emergency_cancel_blocked": True, + "requires_reconcile": True, + "cancel_status": cancel_status or {}, + "cancellation_attempts": cancellation_attempts or [], + } + + def _mark_cancelled_live_buy_records( + self, + symbol: str, + cancelled_orders: list[dict], + ) -> list[dict]: + """브로커에서 취소 확인된 BUY를 인메모리·DB open 상태에서 닫는다.""" + cancelled_by_id = { + self._normalize_broker_order_id(item.get("order_no")): item + for item in cancelled_orders + if self._normalize_broker_order_id(item.get("order_no")) + } + if not cancelled_by_id: + return [] + + for local_order in self.order_book.get_open_orders(symbol): + if str(local_order.action).upper() != "BUY": + continue + broker_id = self._normalize_broker_order_id(local_order.broker_order_id) + if broker_id not in cancelled_by_id: + continue + local_order.transition( + OrderStatus.CANCELLED, + reason="긴급 매도 전 KIS 미체결 BUY 취소 확인", + ) + self._persist_order_record(local_order) + + updated: list[dict] = [] + records = get_open_order_records( + symbol=symbol, + account_key=self.account_key, + mode=self.mode, + ) + + for record in records: + if str(record.get("action") or "").upper() != "BUY": + continue + broker_id = self._normalize_broker_order_id(record.get("broker_order_id")) + cancelled = cancelled_by_id.get(broker_id) + if cancelled is None: + continue + try: + filled_qty = int(cancelled.get("filled_qty") or 0) + except (TypeError, ValueError, OverflowError): + filled_qty = int(record.get("filled_qty") or 0) + reconciled = reconcile_order_record( + record["order_id"], + status=OrderStatus.CANCELLED.value, + filled_qty=max(filled_qty, int(record.get("filled_qty") or 0)), + remaining_qty=0, + reason="emergency_sell_conflicting_buy_cancelled", + ) + if reconciled: + updated.append(reconciled) + return updated + + def _cancel_conflicting_live_buys_before_emergency_sell( + self, + *, + symbol: str, + order, + strategy: str, + ) -> dict | None: + """긴급 SELL 전에 같은 종목의 취소 가능 BUY를 제거한다. + + 미체결 BUY를 둔 채 보유분만 매도하면 이후 BUY가 체결되어 포지션이 + 되살아날 수 있다. KIS가 취소 가능 수량을 확인해 준 주문만 취소하고, + 재조회에서 BUY가 사라진 것이 확인된 경우에만 SELL 경로를 계속한다. + 부분 체결분이 있거나 조회·취소 상태가 불명확하면 수량을 추측하지 않고 + 영속 HALT 후 브로커 대조를 요구한다. + """ + if self.mode != "live": + return None + + status_getter = getattr(self.kis_api, "get_cancelable_order_status", None) + if not callable(status_getter): + # 오래된/테스트용 KIS 어댑터도 기존 미체결 조회에서 "없음"을 + # 확정할 수 있으면 취소할 대상 자체가 없으므로 계속 진행할 수 있다. + # 조회 실패 또는 미체결 존재는 기존 fail-closed 결과를 그대로 쓴다. + return self._live_unfilled_order_block(symbol, order) + + try: + initial_status = status_getter(symbol, "BUY") + except Exception as exc: + initial_status = { + "checked": False, + "reason": "cancelable_query_exception", + "error": str(exc), + "orders": [], + } + if not initial_status.get("checked"): + return self._emergency_cancel_block( + order=order, + reason=( + "긴급 매도 전 취소 가능한 BUY 주문 조회가 실패해 " + "주문을 보류했습니다." + ), + cancel_status=initial_status, + ) + + pending_buys = list(initial_status.get("orders") or []) + if not pending_buys: + return None + + cancel_func = getattr(self.kis_api, "cancel_order", None) + if not callable(cancel_func): + return self._emergency_cancel_block( + order=order, + reason=( + "긴급 매도 전 KIS 미체결 BUY 취소 기능을 사용할 수 없어 " + "주문을 보류했습니다." + ), + cancel_status={ + **initial_status, + "reason": "cancel_api_missing", + }, + ) + + logger.critical( + "긴급 매도 전 미체결 BUY {}건 발견 — 선취소 후 SELL 검증: {}", + len(pending_buys), + symbol, + ) + cancellation_attempts: list[dict] = [] + for pending in pending_buys: + attempt = { + "order_no": pending.get("order_no"), + "cancelable_qty": pending.get("cancelable_qty"), + "filled_qty": pending.get("filled_qty"), + "response_unknown": False, + "accepted": False, + } + try: + result = self._execute_authorized_kis_order( + cancel_func, + pending.get("order_no"), + pending.get("order_branch"), + pending.get("cancelable_qty"), + pending.get("order_type"), + pending.get("exchange_id"), + symbol=symbol, + action="CANCEL", + quantity=int(pending.get("cancelable_qty") or 0), + strategy=strategy, + reason="emergency_sell_conflicting_buy", + ) + if result is ORDER_RESPONSE_UNKNOWN: + attempt["response_unknown"] = True + elif result is not None: + attempt["accepted"] = True + except Exception as exc: + attempt["error"] = str(exc) + cancellation_attempts.append(attempt) + + verified_status: dict | None = None + for verification_attempt in range(self.EMERGENCY_CANCEL_VERIFY_ATTEMPTS): + try: + current_status = status_getter(symbol, "BUY") + except Exception as exc: + current_status = { + "checked": False, + "reason": "cancel_verification_exception", + "error": str(exc), + "orders": [], + } + verified_status = current_status + if current_status.get("checked") and not current_status.get("orders"): + break + if not current_status.get("checked"): + break + if verification_attempt + 1 < self.EMERGENCY_CANCEL_VERIFY_ATTEMPTS: + time_mod.sleep(self.EMERGENCY_CANCEL_VERIFY_DELAY_SECONDS) + + if not verified_status or not verified_status.get("checked") or verified_status.get("orders"): + return self._emergency_cancel_block( + order=order, + reason=( + "미체결 BUY 취소가 브로커 재조회에서 확인되지 않아 " + "긴급 매도를 보류했습니다." + ), + cancel_status=verified_status or {}, + cancellation_attempts=cancellation_attempts, + ) + + try: + cancelled_records = self._mark_cancelled_live_buy_records( + symbol, pending_buys + ) + except Exception as exc: + logger.exception( + "취소 확인 후 로컬 주문 상태 정리 실패 — 긴급 SELL 보류: {} — {}", + symbol, + exc, + ) + return self._emergency_cancel_block( + order=order, + reason=( + "미체결 BUY 취소는 확인됐지만 로컬 주문 상태를 안전하게 " + "정리하지 못해 긴급 매도를 보류했습니다." + ), + cancel_status={ + **verified_status, + "local_reconcile_error": str(exc), + }, + cancellation_attempts=cancellation_attempts, + ) + partial_fills = [ + item for item in pending_buys if int(item.get("filled_qty") or 0) > 0 + ] + if partial_fills: + return self._emergency_cancel_block( + order=order, + reason=( + "기존 BUY의 부분 체결분이 확인되어 실제 보유 수량을 " + "KIS 잔고와 대조하기 전까지 긴급 매도를 보류했습니다." + ), + cancel_status={ + **verified_status, + "partial_fills": partial_fills, + "cancelled_records": cancelled_records, + }, + cancellation_attempts=cancellation_attempts, + ) + + OrderGuard.clear(symbol) + _log_op_event( + "EMERGENCY_BUY_CANCEL_CONFIRMED", + f"긴급 매도 전 미체결 BUY {len(pending_buys)}건 취소 확인: {symbol}", + severity="critical", + symbol=symbol, + strategy=strategy or None, + detail={ + "cancelled_orders": pending_buys, + "cancelled_records": cancelled_records, + }, + mode=self.mode, + ) + return None + def _get_sector_map_cached(self) -> dict: """업종 매핑을 한 번만 조회하고 캐시한다. 실패 시 빈 dict.""" if self._sector_map is None: @@ -383,13 +1021,15 @@ def _monthly_buy_cap_check(self, symbol: str, action: str = "BUY") -> dict: } return {"allowed": True, "reason": ""} - def _daily_loss_baseline_value(self) -> float | None: - """최근 포트폴리오 스냅샷에서 당일 손실 비교 기준값을 가져온다.""" + def _daily_loss_baseline(self) -> dict | None: + """전일 포트폴리오 스냅샷의 값과 현금흐름 경계를 가져온다.""" from database.repositories import get_portfolio_snapshots + from zoneinfo import ZoneInfo snapshots = get_portfolio_snapshots( days=10, account_key=self.account_key if self.account_key else None, + mode=self.mode, ) if snapshots.empty: return None @@ -401,7 +1041,9 @@ def _daily_loss_baseline_value(self) -> float | None: baseline_rows = snapshots if "date" in snapshots.columns: try: - today = datetime.now().date() + # 스냅샷 귀속일과 국내 장 운영일은 KST 기준이다. 배포 호스트가 + # UTC여도 장중의 "오늘" 판정이 전날로 밀리지 않게 한다. + today = datetime.now(ZoneInfo("Asia/Seoul")).date() dated = snapshots.copy() dated["_snapshot_date"] = dated["date"].apply( lambda value: value.date() if hasattr(value, "date") else value @@ -416,7 +1058,33 @@ def _daily_loss_baseline_value(self) -> float | None: latest = baseline_rows.iloc[-1] baseline_value = float(latest.get("total_value") or 0) - return baseline_value if baseline_value > 0 else None + if not math.isfinite(baseline_value) or baseline_value <= 0: + return None + return { + "total_value": baseline_value, + "date": latest.get("date"), + "created_at": latest.get("created_at"), + } + + def _daily_loss_baseline_value(self) -> float | None: + """하위 호환용 전일 평가금액 조회.""" + baseline = self._daily_loss_baseline() + return float(baseline["total_value"]) if baseline else None + + def _is_real_money_live(self) -> bool: + """실전 또는 모의임을 확신할 수 없는 KIS endpoint인지 판정한다.""" + kis = self.config.kis_api or {} + confirmed_mock = ( + kis.get("use_mock") is True + and "openapivts.koreainvestment.com" + in str( + kis.get( + "mock_url", + "https://openapivts.koreainvestment.com:29443", + ) + ).strip().lower() + ) + return self.mode == "live" and not confirmed_mock def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: """MDD/일일 손실 한도 도달 시 신규 BUY만 차단한다.""" @@ -424,8 +1092,30 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: return {"allowed": True, "reason": ""} drawdown_cfg = (self.config.risk_params or {}).get("drawdown") or {} - max_mdd = float(drawdown_cfg.get("max_portfolio_mdd") or 0) - max_daily_loss = float(drawdown_cfg.get("max_daily_loss") or 0) + try: + max_mdd = float(drawdown_cfg.get("max_portfolio_mdd") or 0) + max_daily_loss = float(drawdown_cfg.get("max_daily_loss") or 0) + except (TypeError, ValueError, OverflowError) as exc: + return { + "allowed": False, + "reason": f"손실 한도 설정 오류: {exc}", + "drawdown_guard_blocked": True, + "drawdown_guard_type": "invalid_config", + "mode": self.mode, + } + if ( + not math.isfinite(max_mdd) + or not math.isfinite(max_daily_loss) + or max_mdd < 0 + or max_daily_loss < 0 + ): + return { + "allowed": False, + "reason": "손실 한도 설정에 NaN/Inf 또는 음수가 포함됨", + "drawdown_guard_blocked": True, + "drawdown_guard_type": "invalid_config", + "mode": self.mode, + } if max_mdd <= 0 and max_daily_loss <= 0: return {"allowed": True, "reason": ""} @@ -452,7 +1142,7 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: "mode": self.mode, } total_value = float(summary.get("total_value") or 0) - if total_value <= 0: + if not math.isfinite(total_value) or total_value <= 0: reason = "손실 한도 확인 실패: 포트폴리오 평가금액 없음" logger.warning("신규 매수 차단: {}", reason) return { @@ -464,6 +1154,8 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: } mdd_pct = abs(float(summary.get("mdd") or 0)) + if not math.isfinite(mdd_pct): + raise ValueError("포트폴리오 MDD가 NaN/Inf입니다") mdd_limit_pct = max_mdd * 100 # 히스테리시스 게이트(상시 프로세스용 2차 방어): 상태 갱신(peak·halt 진입/해제)을 @@ -504,9 +1196,48 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: "mode": self.mode, } - baseline_value = self._daily_loss_baseline_value() - if max_daily_loss > 0 and baseline_value: - daily_pnl = total_value - baseline_value + baseline = self._daily_loss_baseline() + if max_daily_loss > 0 and baseline is None and self._is_real_money_live(): + reason = ( + "일일 손실 한도 확인 실패: 전일 포트폴리오 스냅샷이 없어 " + "실계좌 신규 매수를 차단합니다." + ) + logger.warning("신규 매수 차단: {}", reason) + return { + "allowed": False, + "reason": reason, + "drawdown_guard_blocked": True, + "drawdown_guard_type": "daily_loss_baseline_unavailable", + "mode": self.mode, + } + + if max_daily_loss > 0 and baseline: + baseline_value = float(baseline["total_value"]) + if not math.isfinite(baseline_value) or baseline_value <= 0: + raise ValueError("일일 손실 기준 평가금액이 유효하지 않습니다") + + # 입금은 손익이 아니며, 당일 입금이 실제 손실을 가려서도 안 된다. + # 전일 스냅샷 측정 뒤의 순현금흐름을 제거한 경제적 PnL로 한도를 본다. + boundary = baseline.get("created_at") or baseline.get("date") + if hasattr(boundary, "to_pydatetime"): + boundary = boundary.to_pydatetime() + cash_flow = 0.0 + if isinstance(boundary, datetime): + from database.repositories import get_cash_flow_total_between + + cash_flow = get_cash_flow_total_between( + self.account_key, + boundary, + datetime.now(), + mode=self.mode, + ) + cash_flow = float(cash_flow) + if not math.isfinite(cash_flow): + raise ValueError("일일 순현금흐름이 NaN/Inf입니다") + + daily_pnl = total_value - baseline_value - cash_flow + if not math.isfinite(daily_pnl): + raise ValueError("일일 손익이 NaN/Inf입니다") daily_allowed = self.risk_manager.check_daily_loss( daily_pnl, baseline_value, @@ -527,6 +1258,7 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: "daily_loss": round(daily_loss_pct, 2), "daily_loss_limit": round(daily_limit_pct, 2), "daily_loss_baseline": round(baseline_value, 0), + "daily_cash_flow": round(cash_flow, 0), "mode": self.mode, } except Exception as exc: @@ -597,8 +1329,29 @@ def _entry_liquidity_check( def _gap_up_entry_check(self, symbol: str, price: float) -> dict: """갭업 추격매수 방지용 최근 가격 조회는 실패 시 신규 BUY를 차단한다.""" gap_cfg = (self.config.risk_params or {}).get("gap_risk", {}) - if not (gap_cfg.get("enabled", False) and gap_cfg.get("gap_up_entry_block", 0) > 0): + enabled = gap_cfg.get("enabled", False) + if enabled is False or enabled is None: return {"allowed": True, "reason": ""} + if enabled is not True: + return { + "allowed": False, + "reason": "갭 리스크 설정 오류: enabled는 boolean이어야 함", + "gap_risk_blocked": True, + } + try: + threshold = float(gap_cfg.get("gap_up_entry_block")) + except (TypeError, ValueError, OverflowError) as exc: + return { + "allowed": False, + "reason": f"갭 리스크 설정 오류: {exc}", + "gap_risk_blocked": True, + } + if not math.isfinite(threshold) or not (0 < threshold <= 1): + return { + "allowed": False, + "reason": "갭 리스크 설정 오류: gap_up_entry_block은 (0,1]이어야 함", + "gap_risk_blocked": True, + } try: from core.data_collector import DataCollector @@ -637,10 +1390,10 @@ def _gap_up_entry_check(self, symbol: str, price: float) -> dict: return {"allowed": False, "reason": reason, "gap_risk_blocked": True} gap_pct = (today_open - prev_close) / prev_close - if gap_pct >= gap_cfg["gap_up_entry_block"]: + if gap_pct >= threshold: reason = ( f"갭업 +{gap_pct*100:.1f}% " - f"(기준 +{gap_cfg['gap_up_entry_block']*100:.0f}%) — 추격매수 차단" + f"(기준 +{threshold*100:.0f}%) — 추격매수 차단" ) logger.warning("종목 {} 매수 스킵: {}", symbol, reason) return {"allowed": False, "reason": reason, "gap_risk_blocked": True} @@ -674,6 +1427,33 @@ def _positive_order_price(price) -> float | None: return None return order_price + @staticmethod + def _finite_amount(value, *, positive: bool = False) -> float | None: + """주문 경계에서 NaN/Inf와 음수 자금 값을 거부한다.""" + try: + amount = float(value) + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(amount): + return None + if positive and amount <= 0: + return None + if not positive and amount < 0: + return None + return amount + + @staticmethod + def _position_invested_value(position) -> float: + value = getattr(position, "total_invested", None) + if value is None: + value = float(getattr(position, "avg_price", 0) or 0) * int( + getattr(position, "quantity", 0) or 0 + ) + value = float(value) + if not math.isfinite(value) or value < 0: + raise ValueError("포지션 투자금에 NaN/Inf 또는 음수가 포함됨") + return value + def execute_buy( self, symbol: str, @@ -726,6 +1506,14 @@ def _execute_buy_impl( execution_session_id: str = "", ) -> dict: """매수 주문 실제 로직 (Lock 내부에서 호출).""" + halt_check = self._global_trading_halt_check( + "BUY", + symbol=symbol, + strategy=strategy, + ) + if not halt_check["allowed"]: + return {"success": False, **halt_check} + live_gate_check = self._live_buy_gate_check("BUY") if not live_gate_check["allowed"]: return {"success": False, **live_gate_check} @@ -737,6 +1525,30 @@ def _execute_buy_impl( return {"success": False, "reason": reason_text, "price_invalid": True} price = order_price + cash_was_unspecified = available_cash is None + checked_capital = self._finite_amount(capital, positive=True) + checked_cash = self._finite_amount( + capital if available_cash is None else available_cash, + positive=True, + ) + checked_invested = ( + None + if current_invested is None + else self._finite_amount(current_invested) + ) + if ( + checked_capital is None + or checked_cash is None + or current_invested is not None and checked_invested is None + ): + return { + "success": False, + "reason": "자본/가용현금/투자금에 NaN/Inf·0 이하 또는 음수가 포함됨", + "capital_invalid": True, + } + capital = checked_capital + available_cash = checked_cash + if self._should_block_new_buy_volatility_window(): now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.info( @@ -771,8 +1583,24 @@ def _execute_buy_impl( "시장 국면 [{}]: 손절×{:.2f}, 익절×{:.2f}", regime_adj["regime"], regime_sl_mult, regime_tp_mult, ) - except Exception: - pass + except Exception as exc: + # 비활성 필터의 부가 로직 실패는 기존 동작을 유지하되, 필터가 켜진 상태에서 + # 국면 판별 자체가 실패하면 unknown으로 간주해 신규 BUY를 fail-closed 한다. + if bool(self.config.trading.get("market_regime_filter", False)): + reason_text = f"시장 국면 확인 모듈 오류로 신규 매수를 차단합니다: {exc}" + logger.exception("종목 {} 매수 스킵: {}", symbol, reason_text) + return { + "success": False, + "reason": reason_text, + "market_regime_blocked": True, + "market_regime": "unknown", + "market_regime_details": { + "reason": "market_regime_module_failed", + "error": str(exc), + "fail_closed": True, + }, + } + logger.debug("비활성 시장 국면 필터 부가 로직 생략: {}", exc) sizing_costs = self.risk_manager.calculate_transaction_costs( price, @@ -803,7 +1631,27 @@ def _execute_buy_impl( return {"success": False, "reason": "계산된 수량이 0"} # 상관관계 기반 포지션 축소 - positions = get_all_positions(account_key=self.account_key if self.account_key else None) + positions = get_all_positions( + account_key=self.account_key if self.account_key else None, + mode=self.mode, + ) + try: + persisted_invested = sum( + self._position_invested_value(position) for position in positions + ) + # 호출자가 전달한 값이 오래됐거나 작더라도 DB에 이미 존재하는 노출을 + # 무시해 한도를 낮출 수 없게 한다. + checked_invested = max(checked_invested or 0.0, persisted_invested) + if cash_was_unspecified: + # 현금 미전달 시 총자산 전체를 현금으로 간주하지 않는다. + checked_cash = max(0.0, capital - persisted_invested) + available_cash = checked_cash + except (TypeError, ValueError, OverflowError) as exc: + return { + "success": False, + "reason": f"현재 투자금 확인 실패: {exc}", + "capital_invalid": True, + } existing_symbols = [p.symbol for p in positions] corr_result = self.risk_manager.check_correlation_risk(symbol, existing_symbols) if corr_result.get("blocked"): @@ -846,10 +1694,21 @@ def _execute_buy_impl( sizing_entry_price * quantity, capital, available_cash=available_cash, - current_invested=current_invested or 0, + current_invested=checked_invested, symbol=symbol, sector_map=sector_map, positions=positions, + existing_position_value=next( + ( + float(getattr(p, "total_invested", 0) or 0) + for p in positions + if getattr(p, "symbol", "") == symbol + ), + 0.0, + ), + is_new_position=not any( + getattr(p, "symbol", "") == symbol for p in positions + ), ) if not div_check["can_buy"]: logger.warning("종목 {} 매수 불가: {}", symbol, div_check["reason"]) @@ -871,7 +1730,6 @@ def _execute_buy_impl( costs = self.risk_manager.calculate_transaction_costs( price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, ) - available_cash = capital if available_cash is None else available_cash estimated_fill_price = float(costs.get("execution_price", price) or price) # 음수 캐시 방지: 가용 현금이 0 이하이면 즉시 거부 @@ -900,9 +1758,8 @@ def _execute_buy_impl( avg_daily_volume=avg_daily_volume, ) if not pre_check["allowed"]: - result = {"success": False, "reason": pre_check["reason"]} - if pre_check.get("paper_entry_blocked"): - result["paper_entry_blocked"] = True + result = {"success": False, **pre_check} + result.pop("allowed", None) return result # ── 상태기계 기반 주문 처리 ── @@ -931,24 +1788,26 @@ def _execute_buy_impl( actual_slippage_pct = None if self.mode == "live": - ttl_seconds = int(self.config.trading.get("pending_order_ttl_seconds", 600)) + ttl_seconds = self._safe_order_guard_ttl( + "pending_order_ttl_seconds", + default=600, + minimum=60, + ) persistent_block = self._persistent_live_order_block(symbol, order) if persistent_block: return persistent_block - if OrderGuard.has_pending(symbol): - order.transition(OrderStatus.REJECTED, reason="OrderGuard pending") - self._persist_order_record(order) - return {"success": False, "reason": f"{symbol} 종목에 미체결/최근 주문이 남아 있어 중복 주문을 차단했습니다."} live_unfilled_block = self._live_unfilled_order_block(symbol, order) if live_unfilled_block: self._persist_order_record(order) return live_unfilled_block - OrderGuard.mark_pending(symbol, ttl_seconds=ttl_seconds) + guard_block = self._claim_live_order_guard(symbol, order, ttl_seconds) + if guard_block: + return guard_block order.transition(OrderStatus.SUBMITTED) self._persist_order_record(order) - order_result = self._execute_with_retry( + order_result = self._execute_authorized_kis_order( self.kis_api.buy_order, symbol, quantity, int(price), symbol=symbol, action="BUY", price=price, quantity=quantity, strategy=strategy, signal_score=signal_score, reason=reason, @@ -980,7 +1839,6 @@ def _execute_buy_impl( # FILLED 전이 — 이 시점에서만 position/trade 반영 order.transition(OrderStatus.FILLED, fill_qty=quantity, fill_price=fill_price) self._persist_order_record(order) - OrderGuard.clear(symbol) else: # Paper mode: simulated broker event order.transition(OrderStatus.SUBMITTED) @@ -995,50 +1853,64 @@ def _execute_buy_impl( # 4) FILLED 상태에서만 DB 반영 (invariant: fill 전 position 없음) assert order.status == OrderStatus.FILLED, f"DB 반영 시점에 FILLED가 아님: {order.status}" - if self.mode == "live": - costs = self.risk_manager.calculate_transaction_costs( - fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, - ) + _trade = None + try: + if self.mode == "live": + costs = self.risk_manager.calculate_transaction_costs( + fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, + ) - stop_loss = self.risk_manager.calculate_stop_loss( - fill_price, - atr, - regime_multiplier=regime_sl_mult, - ) - tp_info = self.risk_manager.calculate_take_profit( - fill_price, - regime_multiplier=regime_tp_mult, - ) - trailing_stop = self.risk_manager.calculate_trailing_stop(fill_price, atr) - - _order_at = datetime.now() - _trade = save_trade( - symbol=symbol, action="BUY", price=fill_price, quantity=quantity, - commission=costs["commission"], tax=0, slippage=costs["slippage"], - strategy=strategy, signal_score=signal_score, reason=reason, - mode=self.mode, account_key=self.account_key, - signal_at=signal_at or _order_at, order_at=_order_at, - expected_price=expected_price, - actual_slippage_pct=actual_slippage_pct if self.mode == "live" else None, - execution_session_id=execution_session_id, - order_id=order.order_id, - ) - _log_op_event("SIGNAL", f"BUY {symbol} {quantity}주 @ {price:,.0f}원", - symbol=symbol, strategy=strategy, mode=self.mode) + stop_loss = self.risk_manager.calculate_stop_loss( + fill_price, + atr, + regime_multiplier=regime_sl_mult, + ) + tp_info = self.risk_manager.calculate_take_profit( + fill_price, + regime_multiplier=regime_tp_mult, + ) + trailing_stop = self.risk_manager.calculate_trailing_stop(fill_price, atr) - try: + _order_at = datetime.now() + _trade = save_trade( + symbol=symbol, action="BUY", price=fill_price, quantity=quantity, + commission=costs["commission"], tax=0, slippage=costs["slippage"], + strategy=strategy, signal_score=signal_score, reason=reason, + mode=self.mode, account_key=self.account_key, + signal_at=signal_at or _order_at, order_at=_order_at, + expected_price=expected_price, + actual_slippage_pct=actual_slippage_pct if self.mode == "live" else None, + execution_session_id=execution_session_id, + order_id=order.order_id, + ) + _log_op_event("SIGNAL", f"BUY {symbol} {quantity}주 @ {price:,.0f}원", + symbol=symbol, strategy=strategy, mode=self.mode) save_position( symbol=symbol, avg_price=fill_price, quantity=quantity, stop_loss_price=stop_loss, take_profit_price=tp_info["target_final"], trailing_stop_price=trailing_stop, strategy=strategy, account_key=self.account_key, + mode=self.mode, ) - except Exception: + except Exception as exc: # 원장 보상 롤백: 매매만 남으면 현금만 차감된 반쪽 원장 — 유령 낙폭과 # 가드 오발동의 뿌리(2026-07-07 실측). 되돌리고 실패를 위로 알린다. - delete_trade_by_id(_trade.id) + if _trade is not None: + try: + delete_trade_by_id(_trade.id) + except Exception: + logger.exception("체결 후 BUY 매매기록 보상 삭제 실패: {}", symbol) + self._halt_after_post_fill_ledger_failure( + order=order, + action="BUY", + error=exc, + ) raise + if self.mode == "live": + # 브로커 체결과 Trade/Position 원장이 모두 영속화된 뒤에만 해제한다. + OrderGuard.clear(symbol) + # 매매 로그 log_trade("BUY", symbol, fill_price, quantity, reason) @@ -1078,6 +1950,8 @@ def execute_buy_quantity( avg_daily_volume: float = None, atr: float = None, execution_session_id: str = "", + weight_policy_managed: bool = False, + risk_levels: dict = None, ) -> dict: """Execute a fixed-quantity buy (paper or live). @@ -1085,6 +1959,14 @@ def execute_buy_quantity( 목표 비중으로 수량을 이미 정하므로, 일반 1%-룰 사이저가 이를 덮어쓰면 안 된다. live에서도 주문 집행(OrderGuard·미체결조회·체결확인·reconcile)은 일반 매수와 동일한 안전 장치를 거친다. 사이징만 건너뛰고 집행은 동일하다. + + weight_policy_managed: 호출부가 사전 승인된 목표 비중표(baskets.yaml 등)로 + 종목 구성과 상한을 이미 결정했음을 뜻한다. 이 경우 쌍별 상관 거부권만 + 건너뛴다 — 나머지 게이트(노출 상한·업종·유동성·갭·실적·현금·거래중단)는 + 그대로 적용된다. 자세한 근거는 _execute_buy_quantity_impl의 상관 체크 참조. + risk_levels: {"stop_loss_price", "take_profit_price", "trailing_stop_price"}로 + 진입 시 리스크 레벨을 명시 지정한다(None인 키는 미설정). 미전달 시 전역 + risk_params 기준으로 계산한다. """ with PositionLock(): return self._execute_buy_quantity_impl( @@ -1099,6 +1981,8 @@ def execute_buy_quantity( avg_daily_volume=avg_daily_volume, atr=atr, execution_session_id=execution_session_id, + weight_policy_managed=weight_policy_managed, + risk_levels=risk_levels, ) def _execute_buy_quantity_impl( @@ -1114,14 +1998,31 @@ def _execute_buy_quantity_impl( avg_daily_volume: float = None, atr: float = None, execution_session_id: str = "", + weight_policy_managed: bool = False, + risk_levels: dict = None, ) -> dict: # live 고정수량 BUY도 일반 BUY와 동일하게 canonical live gate 통과 executor에서만 허용. # (기존 paper-only 차단을 제거하면서 이 게이트가 그 안전 역할을 승계한다.) + halt_check = self._global_trading_halt_check( + "BUY", + symbol=symbol, + strategy=strategy, + ) + if not halt_check["allowed"]: + return {"success": False, **halt_check} + live_gate_check = self._live_buy_gate_check("BUY") if not live_gate_check["allowed"]: return {"success": False, **live_gate_check} - quantity = int(quantity or 0) + try: + quantity_value = float(quantity) + quantity = int(quantity_value) + except (TypeError, ValueError, OverflowError): + quantity = 0 + quantity_value = 0 + if not math.isfinite(quantity_value) or quantity_value != quantity: + return {"success": False, "reason": "quantity must be a finite integer"} if quantity <= 0: return {"success": False, "reason": "quantity must be positive"} order_price = self._positive_order_price(price) @@ -1133,6 +2034,17 @@ def _execute_buy_quantity_impl( } price = order_price + checked_capital = self._finite_amount(capital, positive=True) + checked_cash = self._finite_amount(available_cash, positive=True) + if checked_capital is None or checked_cash is None: + return { + "success": False, + "reason": "자본/가용현금에 NaN/Inf·0 이하가 포함됨", + "capital_invalid": True, + } + capital = checked_capital + available_cash = checked_cash + if self._should_block_new_buy_volatility_window(): return {"success": False, "reason": "장 초반/마감 진입 차단 시간대"} @@ -1142,7 +2054,13 @@ def _execute_buy_quantity_impl( expected_price = float(price) fill_price = float(costs["execution_price"]) total_required = fill_price * quantity + float(costs.get("commission", 0) or 0) - if total_required > float(available_cash): + if not math.isfinite(total_required) or total_required <= 0: + return { + "success": False, + "reason": "예상 주문금액이 유효하지 않습니다", + "capital_invalid": True, + } + if total_required > available_cash: return { "success": False, "reason": "사용 가능 현금 부족", @@ -1150,6 +2068,9 @@ def _execute_buy_quantity_impl( "available_cash": available_cash, } + # 영속 HALT·월간 cap·drawdown·paper governance·유동성은 외부 가격/실적 + # 조회보다 먼저 확인한다. 이미 차단된 주문이 불필요한 네트워크 호출을 하지 + # 않게 하고, 운영 차단 사유의 우선순위를 일관되게 유지한다. pre_check = self._pre_order_check( symbol=symbol, action="BUY", @@ -1159,13 +2080,250 @@ def _execute_buy_quantity_impl( avg_daily_volume=avg_daily_volume, ) if not pre_check["allowed"]: - result = {"success": False, "reason": pre_check["reason"]} - if pre_check.get("paper_entry_blocked"): - result["paper_entry_blocked"] = True + result = {"success": False, **pre_check} + result.pop("allowed", None) return result - stop_loss = self.risk_manager.calculate_stop_loss(price, atr) - tp_info = self.risk_manager.calculate_take_profit(price) + try: + positions = get_all_positions( + account_key=self.account_key if self.account_key else None, + mode=self.mode, + ) + invested_values = [ + self._position_invested_value(position) for position in positions + ] + except Exception as exc: + return { + "success": False, + "reason": f"현재 포지션 노출 확인 실패: {exc}", + "exposure_check_failed": True, + } + existing_position_value = sum( + value + for position, value in zip(positions, invested_values) + if str(getattr(position, "symbol", "")) == str(symbol) + ) + div_cfg = (self.config.risk_params or {}).get("diversification", {}) or {} + max_sector_ratio = div_cfg.get("max_sector_ratio") + try: + parsed_sector_ratio = ( + None if max_sector_ratio is None else float(max_sector_ratio) + ) + except (TypeError, ValueError, OverflowError): + parsed_sector_ratio = None + need_sector_map = bool( + parsed_sector_ratio is not None + and math.isfinite(parsed_sector_ratio) + and parsed_sector_ratio > 0 + and ( + bool(positions) + or (fill_price * quantity) / capital > parsed_sector_ratio + ) + ) + sector_map = self._get_sector_map_cached() if need_sector_map else None + exposure_check = self.risk_manager.check_diversification( + current_positions=len(positions), + position_value=fill_price * quantity, + total_value=capital, + available_cash=available_cash, + current_invested=sum(invested_values), + symbol=symbol, + sector_map=sector_map, + positions=positions if need_sector_map else None, + existing_position_value=existing_position_value, + is_new_position=not any( + str(getattr(position, "symbol", "")) == str(symbol) + for position in positions + ), + ) + if not exposure_check["can_buy"]: + return { + "success": False, + "reason": exposure_check["reason"], + "exposure_limit_blocked": True, + } + + # 고정수량 어댑터도 일반 BUY의 전략 리스크 필터를 우회할 수 없다. + # 축소 권고가 나온 경우 목표 수량을 조용히 바꾸지 않고 주문을 거부한다. + # + # 예외: weight_policy_managed(사전 승인된 목표 비중표로 집행)면 쌍별 상관 + # 거부권을 적용하지 않는다. 상관 게이트의 목적은 '독립적인 신호들이 우연히 + # 겹쳐 한 방향에 몰리는 것'을 막는 것인데, 목표 비중표는 종목 구성과 종목별 + # 상한을 운영자가 이미 명시로 정해 둔 분산 정책 그 자체다. 그 위에 쌍별 + # 상관 거부권을 또 얹으면 분산을 이중으로 계산하는 셈이고, 하락장에서는 + # 대형주 상관이 일제히 1에 수렴하므로 '설계대로 채우는 주문'이 전부 거부돼 + # 구조적 교착이 된다. 집중도는 max_position_ratio·max_sector_ratio· + # max_investment_ratio가 아래에서 그대로 강제한다. + if weight_policy_managed: + logger.debug( + "종목 {} 쌍별 상관 거부권 미적용 — 사전 승인된 목표 비중표로 집행 " + "(집중도는 비중/업종/노출 상한이 강제)", symbol, + ) + else: + existing_symbols = [str(getattr(p, "symbol", "")) for p in positions] + corr_result = self.risk_manager.check_correlation_risk( + symbol, + existing_symbols, + ) + if corr_result.get("blocked") or float(corr_result.get("scale", 1.0)) < 1.0: + return { + "success": False, + "reason": corr_result.get("reason") or "고상관 포지션 축소 필요", + "correlation_risk_blocked": True, + } + + gap_check = self._gap_up_entry_check(symbol, price) + if not gap_check["allowed"]: + return {"success": False, **gap_check} + + try: + raw_skip_days = self.config.trading.get("skip_earnings_days", 0) + if isinstance(raw_skip_days, bool): + raise ValueError("boolean skip_earnings_days") + skip_earnings_days = int(raw_skip_days) + if skip_earnings_days < 0: + raise ValueError("negative skip_earnings_days") + except (TypeError, ValueError, OverflowError) as exc: + return { + "success": False, + "reason": f"실적 발표일 필터 설정 오류: {exc}", + "earnings_filter_blocked": True, + } + if skip_earnings_days > 0: + try: + from core.earnings_filter import is_near_earnings + + near_earnings, earnings_reason = is_near_earnings( + symbol, + skip_days=skip_earnings_days, + config=self.config, + ) + except Exception as exc: + return { + "success": False, + "reason": f"실적 발표일 확인 실패: {exc}", + "earnings_filter_blocked": True, + } + if near_earnings: + return { + "success": False, + "reason": earnings_reason, + "earnings_filter_blocked": True, + } + + from database.repositories import get_recent_sell_trades + + try: + recent_sells = get_recent_sell_trades( + limit=( + self.risk_manager.risk_params.get( + "performance_degradation", {} + ).get("recent_trades", 20) + ), + mode=self.mode, + account_key=self.account_key if self.account_key else None, + ) + perf_check = self.risk_manager.check_recent_performance(recent_sells) + except Exception as exc: + return { + "success": False, + "reason": f"최근 전략 성과 확인 실패: {exc}", + "performance_check_failed": True, + } + if not perf_check.get("allowed", True): + return { + "success": False, + "reason": perf_check.get("reason", "성과 열화로 매수 중단"), + "performance_degradation_blocked": True, + } + + regime_sl_mult = 1.0 + regime_tp_mult = 1.0 + try: + from core.market_regime import get_regime_adjusted_params + + regime_adj = get_regime_adjusted_params(self.config) + if not regime_adj.get("allow_buys", True): + return { + "success": False, + "reason": ( + "시장 국면 확인 실패 또는 bearish 상태로 고정수량 매수를 " + "차단합니다" + ), + "market_regime_blocked": True, + "market_regime": regime_adj.get("regime", "unknown"), + } + position_scale = float(regime_adj.get("position_scale", 1.0)) + if not math.isfinite(position_scale) or position_scale <= 0: + return { + "success": False, + "reason": "시장 국면 포지션 배수가 유효하지 않습니다", + "market_regime_blocked": True, + } + if position_scale < 1.0: + return { + "success": False, + "reason": ( + f"시장 국면이 포지션 {position_scale:.0%} 축소를 요구하므로 " + "정확한 고정수량 주문을 거부합니다" + ), + "market_regime_blocked": True, + } + regime_sl_mult = float(regime_adj.get("stop_loss_multiplier", 1.0)) + regime_tp_mult = float(regime_adj.get("take_profit_multiplier", 1.0)) + except Exception as exc: + if bool(self.config.trading.get("market_regime_filter", False)): + return { + "success": False, + "reason": f"시장 국면 확인 오류로 고정수량 매수를 차단합니다: {exc}", + "market_regime_blocked": True, + } + + # 목표비중 어댑터도 1회 손실 예산을 확대할 권한은 없다. 정확한 목표 수량이 + # 상한을 넘으면 임의 축소(목표 왜곡) 대신 명시적으로 거부한다. + stop_loss = self.risk_manager.calculate_stop_loss( + fill_price, + atr, + regime_multiplier=regime_sl_mult, + ) + max_risk_rate = float( + (self.config.risk_params.get("position_sizing") or {}).get( + "max_risk_per_trade", 0.01 + ) + ) + projected_loss = (fill_price - stop_loss) * quantity + float( + costs.get("commission", 0) or 0 + ) + risk_budget = capital * max_risk_rate + if ( + not math.isfinite(max_risk_rate) + or not math.isfinite(projected_loss) + or not math.isfinite(risk_budget) + or max_risk_rate <= 0 + or max_risk_rate > 0.05 + or projected_loss <= 0 + or projected_loss > risk_budget + ): + return { + "success": False, + "reason": ( + "고정수량 주문의 예상 손실이 1회 손실 예산을 초과하거나 " + "리스크 입력이 유효하지 않습니다" + ), + "per_trade_risk_blocked": True, + "projected_loss": projected_loss, + "risk_budget": risk_budget, + } + + stop_loss = self.risk_manager.calculate_stop_loss( + fill_price, + atr, + regime_multiplier=regime_sl_mult, + ) + tp_info = self.risk_manager.calculate_take_profit( + price, + regime_multiplier=regime_tp_mult, + ) trailing_stop = self.risk_manager.calculate_trailing_stop(price, atr) order = self.order_book.create_order( @@ -1192,24 +2350,26 @@ def _execute_buy_quantity_impl( # 주문 집행: 사이징만 건너뛰고, 집행 안전장치는 일반 매수와 동일하게 적용한다. actual_slippage_pct = None if self.mode == "live": - ttl_seconds = int(self.config.trading.get("pending_order_ttl_seconds", 600)) + ttl_seconds = self._safe_order_guard_ttl( + "pending_order_ttl_seconds", + default=600, + minimum=60, + ) persistent_block = self._persistent_live_order_block(symbol, order) if persistent_block: return persistent_block - if OrderGuard.has_pending(symbol): - order.transition(OrderStatus.REJECTED, reason="OrderGuard pending") - self._persist_order_record(order) - return {"success": False, "reason": f"{symbol} 종목에 미체결/최근 주문이 남아 있어 중복 주문을 차단했습니다."} live_unfilled_block = self._live_unfilled_order_block(symbol, order) if live_unfilled_block: self._persist_order_record(order) return live_unfilled_block - OrderGuard.mark_pending(symbol, ttl_seconds=ttl_seconds) + guard_block = self._claim_live_order_guard(symbol, order, ttl_seconds) + if guard_block: + return guard_block order.transition(OrderStatus.SUBMITTED) self._persist_order_record(order) - order_result = self._execute_with_retry( + order_result = self._execute_authorized_kis_order( self.kis_api.buy_order, symbol, quantity, int(price), symbol=symbol, action="BUY", price=price, quantity=quantity, strategy=strategy, signal_score=signal_score, reason=reason, @@ -1238,7 +2398,6 @@ def _execute_buy_quantity_impl( self._report_execution_slippage(symbol, "BUY", expected_price, fill_price, actual_slippage_pct) order.transition(OrderStatus.FILLED, fill_qty=quantity, fill_price=fill_price) self._persist_order_record(order) - OrderGuard.clear(symbol) # 체결가 기준 비용 재계산 costs = self.risk_manager.calculate_transaction_costs( fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, @@ -1249,54 +2408,79 @@ def _execute_buy_quantity_impl( order.transition(OrderStatus.FILLED, fill_qty=quantity, fill_price=fill_price) assert order.status == OrderStatus.FILLED, f"DB 반영 시점에 FILLED가 아님: {order.status}" - stop_loss = self.risk_manager.calculate_stop_loss(fill_price, atr) - tp_info = self.risk_manager.calculate_take_profit(fill_price) - trailing_stop = self.risk_manager.calculate_trailing_stop(fill_price, atr) - - _order_at = datetime.now() - _trade = save_trade( - symbol=symbol, - action="BUY", - price=fill_price, - quantity=quantity, - commission=costs["commission"], - tax=0, - slippage=costs["slippage"], - strategy=strategy, - signal_score=signal_score, - reason=reason, - mode=self.mode, - account_key=self.account_key, - signal_at=_order_at, - order_at=_order_at, - expected_price=expected_price, - actual_slippage_pct=actual_slippage_pct if self.mode == "live" else None, - execution_session_id=execution_session_id, - order_id=order.order_id, - ) - _log_op_event( - "SIGNAL", - f"BUY {symbol} {quantity}주 @ {price:,.0f}원", - symbol=symbol, - strategy=strategy, - mode=self.mode, - ) + _trade = None try: + # 호출부가 트랙 정책으로 레벨을 명시했으면 그것을 쓴다. 전역 risk_params는 + # 단타 기준(진입가 -3% 손절 / +8% 익절 / 고점 -5% 트레일링)이라, 저회전 + # buy&hold 바스켓에 그대로 적히면 '있지도 않은 안전망'이 장부에 남는다. + # 명시 지정 시 None인 항목은 '해당 트랙에 그 장치 없음'을 뜻한다. + if risk_levels is not None: + stop_loss = risk_levels.get("stop_loss_price") + take_profit_price = risk_levels.get("take_profit_price") + trailing_stop = risk_levels.get("trailing_stop_price") + else: + stop_loss = self.risk_manager.calculate_stop_loss(fill_price, atr) + take_profit_price = self.risk_manager.calculate_take_profit( + fill_price, + )["target_final"] + trailing_stop = self.risk_manager.calculate_trailing_stop(fill_price, atr) + + _order_at = datetime.now() + _trade = save_trade( + symbol=symbol, + action="BUY", + price=fill_price, + quantity=quantity, + commission=costs["commission"], + tax=0, + slippage=costs["slippage"], + strategy=strategy, + signal_score=signal_score, + reason=reason, + mode=self.mode, + account_key=self.account_key, + signal_at=_order_at, + order_at=_order_at, + expected_price=expected_price, + actual_slippage_pct=actual_slippage_pct if self.mode == "live" else None, + execution_session_id=execution_session_id, + order_id=order.order_id, + ) + _log_op_event( + "SIGNAL", + f"BUY {symbol} {quantity}주 @ {price:,.0f}원", + symbol=symbol, + strategy=strategy, + mode=self.mode, + ) save_position( symbol=symbol, avg_price=fill_price, quantity=quantity, stop_loss_price=stop_loss, - take_profit_price=tp_info["target_final"], + take_profit_price=take_profit_price, trailing_stop_price=trailing_stop, strategy=strategy, account_key=self.account_key, + mode=self.mode, ) - except Exception: + except Exception as exc: # 원장 보상 롤백: 매매만 남으면 현금만 차감된 반쪽 원장 — 유령 낙폭과 # 가드 오발동의 뿌리(2026-07-07 실측). 되돌리고 실패를 위로 알린다. - delete_trade_by_id(_trade.id) + if _trade is not None: + try: + delete_trade_by_id(_trade.id) + except Exception: + logger.exception("체결 후 고정수량 BUY 매매기록 보상 삭제 실패: {}", symbol) + self._halt_after_post_fill_ledger_failure( + order=order, + action="BUY", + error=exc, + ) raise + + if self.mode == "live": + OrderGuard.clear(symbol) log_trade("BUY", symbol, fill_price, quantity, reason) result = { @@ -1307,17 +2491,18 @@ def _execute_buy_quantity_impl( "quantity": quantity, "total_amount": fill_price * quantity, "stop_loss": stop_loss, - "take_profit": tp_info["target_final"], + "take_profit": take_profit_price, "trailing_stop": trailing_stop, "costs": costs, "mode": self.mode, - "paper_fixed_quantity": True, + "fixed_quantity": True, + "paper_fixed_quantity": self.mode == "paper", "execution_session_id": execution_session_id, "order_id": order.order_id, } logger.info( - "✅ 고정수량 paper 매수 완료: {} {}주 @ {:,.0f}원", - symbol, quantity, fill_price, + "✅ 고정수량 {} 매수 완료: {} {}주 @ {:,.0f}원", + self.mode, symbol, quantity, fill_price, ) return result @@ -1362,19 +2547,41 @@ def _execute_sell_impl( execution_session_id: str = "", ) -> dict: """매도 주문 실제 로직 (Lock 내부에서 호출).""" - position = get_position(symbol, account_key=self.account_key) + position = get_position(symbol, account_key=self.account_key, mode=self.mode) if not position: logger.warning("종목 {} 보유 포지션 없음 — 매도 스킵", symbol) return {"success": False, "reason": "보유 포지션 없음"} + # 손실 방어 청산은 지정가가 현재가를 뒤쫓지 못해 미체결되는 위험보다 + # 체결 확률을 우선한다. 현재가가 끊겨도 실전 시장가 주문은 평균단가를 + # 손익 추정 기준으로 삼아 계속 진행한다. + is_emergency = self._is_emergency_sell_reason(reason) order_price = self._positive_order_price(price) if order_price is None: - reason_text = f"매도 가격 확인 실패: {symbol} 현재가 없음" - logger.warning("종목 {} 매도 스킵: {}", symbol, reason_text) - return {"success": False, "reason": reason_text, "price_invalid": True} + fallback_price = self._positive_order_price( + getattr(position, "avg_price", None) + ) + if self.mode == "live" and is_emergency and fallback_price is not None: + order_price = fallback_price + logger.critical( + "긴급 실전 청산 현재가 없음 — 평균단가를 참조가로 시장가 제출: {}", + symbol, + ) + else: + reason_text = f"매도 가격 확인 실패: {symbol} 현재가 없음" + logger.warning("종목 {} 매도 스킵: {}", symbol, reason_text) + return {"success": False, "reason": reason_text, "price_invalid": True} price = order_price - sell_qty = position.quantity if quantity is None else int(quantity) + raw_sell_qty = position.quantity if quantity is None else quantity + try: + sell_qty_value = float(raw_sell_qty) + sell_qty = int(sell_qty_value) + except (TypeError, ValueError, OverflowError): + sell_qty = 0 + sell_qty_value = 0 + if not math.isfinite(sell_qty_value) or sell_qty_value != sell_qty: + return {"success": False, "reason": "매도 수량은 유한한 정수여야 합니다"} if sell_qty <= 0: logger.warning("종목 {} 매도 수량 오류: {}", symbol, sell_qty) return {"success": False, "reason": "매도 수량은 1주 이상이어야 합니다"} @@ -1393,7 +2600,6 @@ def _execute_sell_impl( } # 최소 보유 기간 검사 (손실 방어용 긴급 청산은 예외) - is_emergency = self._is_emergency_sell_reason(reason) if not is_emergency: min_hold = self._get_min_holding_days() if min_hold > 0 and getattr(position, "bought_at", None): @@ -1422,25 +2628,41 @@ def _execute_sell_impl( ) if self.mode == "live": - ttl_seconds = int(self.config.trading.get("pending_order_ttl_seconds", 600)) + ttl_seconds = self._safe_order_guard_ttl( + "pending_order_ttl_seconds", + default=600, + minimum=60, + ) + if is_emergency: + cancel_block = self._cancel_conflicting_live_buys_before_emergency_sell( + symbol=symbol, + order=order, + strategy=strategy, + ) + if cancel_block: + return cancel_block persistent_block = self._persistent_live_order_block(symbol, order) if persistent_block: return persistent_block - if OrderGuard.has_pending(symbol): - order.transition(OrderStatus.REJECTED, reason="OrderGuard pending") - self._persist_order_record(order) - return {"success": False, "reason": f"{symbol} 종목에 미체결/최근 주문이 남아 있어 중복 주문을 차단했습니다."} live_unfilled_block = self._live_unfilled_order_block(symbol, order) if live_unfilled_block: self._persist_order_record(order) return live_unfilled_block - OrderGuard.mark_pending(symbol, ttl_seconds=ttl_seconds) + guard_block = self._claim_live_order_guard(symbol, order, ttl_seconds) + if guard_block: + return guard_block order.transition(OrderStatus.SUBMITTED) self._persist_order_record(order) - order_result = self._execute_with_retry( - self.kis_api.sell_order, symbol, sell_qty, int(price), + broker_order_price = 0 if is_emergency else int(price) + broker_order_type = "01" if is_emergency else "00" + order_result = self._execute_authorized_kis_order( + self.kis_api.sell_order, + symbol, + sell_qty, + broker_order_price, + broker_order_type, symbol=symbol, action="SELL", price=price, quantity=sell_qty, strategy=strategy, signal_score=signal_score, reason=reason, ) @@ -1470,7 +2692,6 @@ def _execute_sell_impl( self._report_execution_slippage(symbol, "SELL", expected_price, fill_price, actual_slippage_pct) order.transition(OrderStatus.FILLED, fill_qty=sell_qty, fill_price=fill_price) self._persist_order_record(order) - OrderGuard.clear(symbol) else: # Paper mode: simulated broker event costs = self.risk_manager.calculate_transaction_costs( @@ -1489,42 +2710,57 @@ def _execute_sell_impl( # FILLED 상태에서만 DB 반영 (invariant) assert order.status == OrderStatus.FILLED, f"SELL DB 반영 시점에 FILLED가 아님: {order.status}" - if self.mode == "live": - costs = self.risk_manager.calculate_transaction_costs( - fill_price, - sell_qty, - "SELL", - avg_daily_volume=avg_daily_volume, - avg_price=float(position.avg_price), - symbol=symbol, - ) - total_tax = costs["tax"] + costs.get("capital_gains_tax", 0) - pnl = (fill_price - position.avg_price) * sell_qty - costs["commission"] - total_tax - pnl_rate = ((fill_price / position.avg_price) - 1) * 100 - - _trade = save_trade( - symbol=symbol, action="SELL", price=fill_price, quantity=sell_qty, - commission=costs["commission"], tax=total_tax, slippage=costs["slippage"], - strategy=strategy, signal_score=signal_score, - reason=f"{reason} | PnL: {pnl:,.0f}원 ({pnl_rate:.2f}%)", - mode=self.mode, account_key=self.account_key, - expected_price=expected_price, - actual_slippage_pct=actual_slippage_pct if self.mode == "live" else None, - execution_session_id=execution_session_id, - order_id=order.order_id, - ) - + _trade = None remaining_pos = None try: + if self.mode == "live": + costs = self.risk_manager.calculate_transaction_costs( + fill_price, + sell_qty, + "SELL", + avg_daily_volume=avg_daily_volume, + avg_price=float(position.avg_price), + symbol=symbol, + ) + total_tax = costs["tax"] + costs.get("capital_gains_tax", 0) + pnl = (fill_price - position.avg_price) * sell_qty - costs["commission"] - total_tax + pnl_rate = ((fill_price / position.avg_price) - 1) * 100 + + _trade = save_trade( + symbol=symbol, action="SELL", price=fill_price, quantity=sell_qty, + commission=costs["commission"], tax=total_tax, slippage=costs["slippage"], + strategy=strategy, signal_score=signal_score, + reason=f"{reason} | PnL: {pnl:,.0f}원 ({pnl_rate:.2f}%)", + mode=self.mode, account_key=self.account_key, + expected_price=expected_price, + actual_slippage_pct=actual_slippage_pct if self.mode == "live" else None, + execution_session_id=execution_session_id, + order_id=order.order_id, + ) + if sell_qty >= position.quantity: - delete_position(symbol, account_key=self.account_key) + delete_position(symbol, account_key=self.account_key, mode=self.mode) else: - remaining_pos = reduce_position(symbol, sell_qty, account_key=self.account_key) - except Exception: + remaining_pos = reduce_position( + symbol, sell_qty, account_key=self.account_key, mode=self.mode + ) + except Exception as exc: # 원장 보상 롤백(매수와 대칭): 매도 기록만 남고 포지션이 그대로면 # 현금이 이중 계상된 반쪽 원장이 된다. 되돌리고 실패를 위로 알린다. - delete_trade_by_id(_trade.id) + if _trade is not None: + try: + delete_trade_by_id(_trade.id) + except Exception: + logger.exception("체결 후 SELL 매매기록 보상 삭제 실패: {}", symbol) + self._halt_after_post_fill_ledger_failure( + order=order, + action="SELL", + error=exc, + ) raise + + if self.mode == "live": + OrderGuard.clear(symbol) # 부분 익절 플래그 갱신은 보상 범위 밖 — 이 시점엔 매도·포지션 반영이 모두 # 끝나 원장이 정합이고, 플래그 실패에 매매를 되돌리면 오히려 원장이 깨진다. if remaining_pos and reason == "TAKE_PROFIT_PARTIAL": @@ -1534,7 +2770,7 @@ def _execute_sell_impl( # 부분 익절 완료 표시를 영속화해 다음 모니터링 사이클에서 재발동되지 않게 한다. update_position_targets( symbol, take_profit_price=round(final_target, 0), - account_key=self.account_key, partial_tp_done=True, + account_key=self.account_key, partial_tp_done=True, mode=self.mode, ) # 매매 로그 @@ -1584,7 +2820,7 @@ def check_stop_loss_take_profit(self, symbol: str, current_price: float) -> dict def _check_stop_loss_take_profit_impl(self, symbol: str, current_price: float) -> dict: """SL/TP 실제 로직 (Lock 내부에서 호출).""" - position = get_position(symbol, account_key=self.account_key) + position = get_position(symbol, account_key=self.account_key, mode=self.mode) if not position: return {"action": None} @@ -1601,7 +2837,13 @@ def _check_stop_loss_take_profit_impl(self, symbol: str, current_price: float) - ).get("fixed_rate", 0.03) from database.repositories import update_trailing_stop - update_trailing_stop(symbol, current_price, trailing_rate, account_key=self.account_key) + update_trailing_stop( + symbol, + current_price, + trailing_rate, + account_key=self.account_key, + mode=self.mode, + ) # 1. 익절 체크 (최종 목표가 도달 → 전량 매도) if position.take_profit_price and current_price >= position.take_profit_price: @@ -1636,7 +2878,7 @@ def _check_stop_loss_take_profit_impl(self, symbol: str, current_price: float) - # 3. 트레일링 스탑 체크 (이익 보호) # position 재조회 (trailing_stop_price가 업데이트되었을 수 있음) - position = get_position(symbol, account_key=self.account_key) + position = get_position(symbol, account_key=self.account_key, mode=self.mode) if position and position.trailing_stop_price and current_price <= position.trailing_stop_price: logger.warning( "📉 트레일링 스탑 발동: {} 현재가={:,.0f} ≤ 스탑가={:,.0f}", @@ -1931,6 +3173,11 @@ def _unknown_response_result(self, order, action: str) -> dict: 다음 KIS↔DB 동기화에서 실제 체결분을 대조하게 한다. """ self._persist_order_record(order) + self._halt_for_uncertain_live_execution( + order=order, + action=action, + reason="broker_order_response_unknown", + ) logger.warning( "실전 주문 응답 유실 — 접수 여부 불명, 장부 반영 보류·reconcile 대기: {} {} order_id={} status={}", action, order.symbol, order.order_id, order.status.value, @@ -1956,6 +3203,12 @@ def _pending_live_execution_result( execution: dict, ) -> dict: self._persist_order_record(order) + self._halt_for_uncertain_live_execution( + order=order, + action=action, + reason=str(execution.get("reason") or "live_fill_unconfirmed"), + execution=execution, + ) logger.warning( "실전 주문 체결 미확인 — 장부 반영 보류: {} {} order_id={} broker_order_id={} reason={}", action, @@ -2154,6 +3407,16 @@ def _pre_order_check( Returns: {"allowed": True/False, "reason": 사유} """ + # BUY 진입 초기에 확인했더라도 실제 주문 생성 직전에 + # 다시 읽어, 그 사이 긴급 청산 HALT가 설정된 경쟁을 차단한다. + halt_check = self._global_trading_halt_check( + action, + symbol=symbol, + strategy=strategy, + ) + if not halt_check["allowed"]: + return halt_check + monthly_cap = self._monthly_buy_cap_check(symbol, action) if not monthly_cap["allowed"]: return monthly_cap @@ -2204,6 +3467,13 @@ def _pre_order_check( return {"allowed": True, "reason": ""} + def _execute_authorized_kis_order(self, order_func, *args, **kwargs): + """저수준 KIS 실주문 API를 이 executor의 검증 구간에서만 연다.""" + from api.kis_api import authorized_kis_order_submission + + with authorized_kis_order_submission(): + return self._execute_with_retry(order_func, *args, **kwargs) + def _execute_with_retry( self, order_func, diff --git a/core/order_guard.py b/core/order_guard.py index ed786829..47d8f168 100644 --- a/core/order_guard.py +++ b/core/order_guard.py @@ -24,6 +24,12 @@ class OrderGuard: @classmethod def has_pending(cls, symbol: str) -> bool: + """유효한 가드가 있거나 상태를 안전하게 확인할 수 없으면 ``True``. + + 주문 경로에서 DB 장애를 "가드 없음"으로 해석하면 같은 주문을 다시 + 보낼 수 있다. 조회 실패는 일시적인 주문 중단보다 훨씬 위험하므로 + fail-closed로 취급한다. + """ with _lock: cls._purge_expired() # 1차: 인메모리 캐시 확인 @@ -33,20 +39,37 @@ def has_pending(cls, symbol: str) -> bool: try: from database.repositories import has_pending_order_guard return has_pending_order_guard(symbol) - except Exception: - return False + except Exception as exc: + logger.error( + "OrderGuard DB 조회 실패 — 중복 주문 방지를 위해 pending으로 간주: {}", + exc, + ) + return True @classmethod - def mark_pending(cls, symbol: str, ttl_seconds: int) -> None: + def mark_pending(cls, symbol: str, ttl_seconds: int) -> bool: + """동일 종목 주문권을 원자적으로 획득한다. + + ``has_pending()`` 뒤 별도 upsert를 하는 check-then-set 방식은 서로 + 다른 프로세스가 동시에 통과할 수 있다. DB UNIQUE 제약을 이용한 + ``claim_order_guard`` 한 번으로 판정과 기록을 묶는다. ``False``는 + 다른 실행 주체가 이미 주문권을 갖고 있다는 뜻이고, DB 오류는 + 호출자에게 전파해 주문을 fail-closed 시킨다. + """ with _lock: + cls._purge_expired() + if symbol in _pending_orders: + return False + expires_at = datetime.now() + timedelta(seconds=max(1, ttl_seconds)) + from database.repositories import claim_order_guard + + claimed = claim_order_guard(symbol, expires_at) + if not claimed: + return False + _pending_orders[symbol] = expires_at - # DB에도 기록 - try: - from database.repositories import save_order_guard - save_order_guard(symbol, expires_at) - except Exception as e: - logger.debug("OrderGuard DB 저장 실패 (인메모리만 사용): {}", e) + return True @classmethod def clear(cls, symbol: str) -> None: @@ -58,6 +81,18 @@ def clear(cls, symbol: str) -> None: except Exception: pass + @classmethod + def extend_pending(cls, symbol: str, ttl_seconds: int) -> None: + """이미 획득한 가드를 원장 재조정용 장기 TTL로 연장한다.""" + with _lock: + expires_at = datetime.now() + timedelta(seconds=max(1, ttl_seconds)) + _pending_orders[symbol] = expires_at + from database.repositories import save_order_guard + + # save_order_guard는 기존 행을 갱신한다. DB 장애가 나더라도 인메모리 + # 가드는 현재 프로세스에서 유지되며, 호출자는 별도로 global HALT를 건다. + save_order_guard(symbol, expires_at) + @classmethod def _purge_expired(cls) -> None: now = datetime.now() diff --git a/core/paper_evidence.py b/core/paper_evidence.py index 6ca38ae2..0cb65a3c 100644 --- a/core/paper_evidence.py +++ b/core/paper_evidence.py @@ -404,6 +404,7 @@ def _probe_portfolio_metrics(account_key: str, date: datetime) -> dict: snap = ( session.query(PortfolioSnapshot) .filter( + PortfolioSnapshot.mode == "paper", PortfolioSnapshot.account_key == ak, PortfolioSnapshot.date >= day_start, PortfolioSnapshot.date < day_end, @@ -437,6 +438,7 @@ def _probe_portfolio_metrics(account_key: str, date: datetime) -> dict: prev_snap = ( session.query(PortfolioSnapshot) .filter( + PortfolioSnapshot.mode == "paper", PortfolioSnapshot.account_key == ak, PortfolioSnapshot.date < day_start, ) @@ -646,7 +648,10 @@ def _collect_execution_ops_metrics( # --- phantom position --- positions = ( session.query(Position) - .filter(Position.account_key == (account_key or "")) + .filter( + Position.mode == "paper", + Position.account_key == (account_key or ""), + ) .all() ) lookback = date - timedelta(days=90) @@ -913,6 +918,7 @@ def _cross_validate(portfolio: dict, trades: dict, account_key: str, date: datet snap = ( session.query(PortfolioSnapshot) .filter( + PortfolioSnapshot.mode == "paper", PortfolioSnapshot.account_key == (account_key or ""), PortfolioSnapshot.date >= day_start, PortfolioSnapshot.date < day_end, diff --git a/core/paper_pilot.py b/core/paper_pilot.py index 6b020378..260b0ee6 100644 --- a/core/paper_pilot.py +++ b/core/paper_pilot.py @@ -739,14 +739,14 @@ def _count_orders_today(strategy: str, as_of_date: str | datetime | None = None) def _count_positions(strategy: str) -> int: from database.repositories import get_all_positions - positions = get_all_positions(account_key=strategy) + positions = get_all_positions(account_key=strategy, mode="paper") return len(positions) if positions else 0 def _get_gross_exposure(strategy: str) -> float: from database.repositories import get_all_positions - positions = get_all_positions(account_key=strategy) + positions = get_all_positions(account_key=strategy, mode="paper") if not positions: return 0 return sum((p.avg_price or 0) * (p.quantity or 0) for p in positions) diff --git a/core/paper_preflight.py b/core/paper_preflight.py index d36b3c85..a3d18b5a 100644 --- a/core/paper_preflight.py +++ b/core/paper_preflight.py @@ -370,7 +370,7 @@ def _check_db_health(checks): def _check_positions_orders(strategy, result, checks, actions): try: from database.repositories import get_all_positions, get_pending_failed_orders - positions = get_all_positions(account_key=strategy) + positions = get_all_positions(account_key=strategy, mode="paper") result.open_positions = len(positions) if positions else 0 pending = get_pending_failed_orders() result.pending_orders = len(pending) if pending else 0 diff --git a/core/performance_lens.py b/core/performance_lens.py new file mode 100644 index 00000000..f7029443 --- /dev/null +++ b/core/performance_lens.py @@ -0,0 +1,256 @@ +"""성과를 '한 숫자'가 아니라 국면·리스크로 나눠 보는 렌즈. 전부 순수 함수. + +왜 필요한가(2026-08-26 점검에서 드러난 사각): +방어적 포지션(주식 60% + 현금 40%)은 하락장에서 **항상** 좋아 보인다. 전체 구간 +수익률 하나로 보고하면 상승장 미스가 통째로 숨는다. 실측이 그랬다 — + + 전체 (6/10~8/26) KOSPI -12.43% vs NAV -4.61% → +7.81%p (좋아 보임) + 반등 (8/07~8/26) KOSPI +8.17% vs NAV -0.80% → -8.97%p (숨어 있던 미스) + +같은 포트폴리오, 같은 3개월이다. 두 숫자를 나란히 보고해야 '방어의 대가'가 보인다. +또 하나: daily_return이 8/10에야 복구돼서 그전엔 변동성·샤프를 아예 계산할 수 없었다 +(스냅샷 61행이 전부 0.0). 이제 계산 가능해졌으니 표면화한다. +""" + +from __future__ import annotations + +import math +from typing import Any, Iterable, Sequence + +_TRADING_DAYS = 252 + + +def _finite(values: Iterable[Any]) -> list[float]: + out: list[float] = [] + for v in values or []: + try: + f = float(v) + except (TypeError, ValueError): + continue + if math.isfinite(f): + out.append(f) + return out + + +def daily_returns_from_nav( + nav_points: Sequence[tuple[Any, Any]], + flows: dict | None = None, +) -> list[tuple[Any, float]]: + """(날짜, NAV) 시계열에서 일간 수익률(%)을 계산한다. [(날짜, 수익률%), ...] + + 스냅샷의 `daily_return` 열을 쓰지 않는 이유: 그 열은 2026-08-10 이전 전 구간이 + 0.0이다(save_daily_snapshot이 값을 넘기지 않던 버그). 그대로 쓰면 변동성이 + 0으로 깔려 '없는 안정성'을 주장하게 된다. NAV 시계열은 처음부터 온전하므로 + 거기서 직접 뽑는 편이 항상 정직하다. + + flows: {날짜: 그날 유입액} — 입금은 수익이 아니므로 분모에서 중화한다. + """ + flows = flows or {} + out: list[tuple[Any, float]] = [] + prev_val = None + for day, value in nav_points or []: + try: + v = float(value) + except (TypeError, ValueError): + continue + if not math.isfinite(v) or v <= 0: + continue + if prev_val is not None and prev_val > 0: + key = day.date() if hasattr(day, "date") else day + flow = float(flows.get(key, 0) or 0) + # 저장소 표준과 동일: r = v_now / (v_prev + flow) - 1 + # (core.portfolio_manager.twr_period_return — 입금은 구간 시작 유입으로 보고 + # 분모에서만 중화한다. 분자에서도 빼면 이중 차감이 된다.) + denom = prev_val + flow + if denom > 0: + out.append((day, (v / denom - 1) * 100.0)) + prev_val = v + return out + + +def aligned_returns( + nav_points: Sequence[tuple[Any, Any]], + bench_closes: dict, + flows: dict | None = None, +) -> list[tuple[Any, float, float]]: + """NAV와 벤치마크를 **같은 구간**으로 맞춰 [(날짜, 내수익률%, 벤치수익률%)]를 낸다. + + 스냅샷이 하루 빠지면(휴장·PC 미가동) NAV 수익률은 이틀치 구간이 되는데 벤치마크를 + 하루치로 짝지으면 기간이 어긋나 국면 분해가 통째로 왜곡된다 — 실제로 1차 구현에서 + 상승 국면 벤치마크가 +149%로 나와 전체 수익률과 아귀가 안 맞았다. + 그래서 일간 수익률이 아니라 **종가 레벨**을 받아 NAV와 동일한 (이전 스냅샷 → 이번 + 스냅샷) 구간으로 벤치마크 수익률을 다시 계산한다. + + 양쪽 종가가 다 있는 구간만 반환한다(한쪽이 없으면 비교 불가 — 버린다). + """ + flows = flows or {} + + def _key(d: Any): + return d.date() if hasattr(d, "date") else d + + out: list[tuple[Any, float, float]] = [] + prev_day = prev_val = None + for day, value in nav_points or []: + try: + v = float(value) + except (TypeError, ValueError): + continue + if not math.isfinite(v) or v <= 0: + continue + if prev_val is not None and prev_val > 0: + b_prev = bench_closes.get(_key(prev_day)) + b_cur = bench_closes.get(_key(day)) + if b_prev and b_cur and float(b_prev) > 0: + flow = float(flows.get(_key(day), 0) or 0) + denom = prev_val + flow + if denom > 0: + mine = (v / denom - 1) * 100.0 + bench = (float(b_cur) / float(b_prev) - 1) * 100.0 + out.append((day, mine, bench)) + prev_day, prev_val = day, v + return out + + +def risk_metrics(daily_returns_pct: Sequence[Any]) -> dict[str, Any]: + """일간 수익률(%) 시계열에서 변동성·샤프·하락일 비율을 낸다. + + 표본이 2개 미만이면 계산 가능한 항목만 채우고 나머지는 None으로 둔다 — 없는 값을 + 0으로 채우면 '변동성 0'처럼 읽혀 없는 안정성을 주장하게 된다. + + 무위험수익률은 0으로 둔다(연 단위 환산만 하는 상대 지표로 쓴다). + """ + rs = _finite(daily_returns_pct) + n = len(rs) + base: dict[str, Any] = { + "samples": n, + "mean_daily_pct": None, + "vol_daily_pct": None, + "vol_annual_pct": None, + "sharpe_annual": None, + "down_day_ratio": None, + "worst_day_pct": None, + "best_day_pct": None, + } + if n == 0: + return base + + base["mean_daily_pct"] = sum(rs) / n + base["worst_day_pct"] = min(rs) + base["best_day_pct"] = max(rs) + base["down_day_ratio"] = sum(1 for r in rs if r < 0) / n + if n < 2: + return base + + mean = base["mean_daily_pct"] + var = sum((r - mean) ** 2 for r in rs) / (n - 1) + sd = math.sqrt(var) + base["vol_daily_pct"] = sd + ann_vol = sd * math.sqrt(_TRADING_DAYS) + base["vol_annual_pct"] = ann_vol + if ann_vol > 0: + base["sharpe_annual"] = (mean * _TRADING_DAYS) / ann_vol + return base + + +def split_by_regime( + pairs: Sequence[tuple[Any, Any]], +) -> dict[str, dict[str, Any]]: + """(내 일간수익률%, 벤치마크 일간수익률%) 쌍을 벤치마크 부호로 갈라 집계한다. + + 상승 국면(벤치 > 0)과 하락 국면(벤치 < 0)에서 각각 얼마나 따라갔는지를 본다. + 벤치가 정확히 0인 날은 어느 쪽도 아니므로 제외한다. + + 각 국면 반환: + days 그 국면 일수 + mine_pct 그 국면 구간들만 이어 붙인 복리 수익률 + bench_pct 같은 구간 벤치마크 복리 수익률 + gap_pct mine - bench + capture 벤치 대비 포착률(bench_pct가 0이 아닐 때). 상승 1.0=완전 추종, + 하락 1.0=완전 노출(낮을수록 방어). 방향 해석은 호출부가 한다. + """ + up_mine: list[float] = [] + up_bench: list[float] = [] + down_mine: list[float] = [] + down_bench: list[float] = [] + + for mine, bench in pairs or []: + try: + m = float(mine) + b = float(bench) + except (TypeError, ValueError): + continue + if not (math.isfinite(m) and math.isfinite(b)) or b == 0: + continue + if b > 0: + up_mine.append(m) + up_bench.append(b) + else: + down_mine.append(m) + down_bench.append(b) + + def _compound(rs: list[float]) -> float: + """일간 수익률(%)을 복리로 누적한다. + + 단순 합산하면 안 된다 — 변동성이 큰 시장에서 31일을 더하면 +94% 같은 숫자가 + 나온다(2026-08-26 1차 구현의 실제 오류). 국면 수익률은 그 국면의 날들만 + 이어 붙인 복리 수익률로 정의한다. + """ + acc = 1.0 + for r in rs: + acc *= 1 + r / 100.0 + return (acc - 1) * 100.0 + + def _agg(mine: list[float], bench: list[float]) -> dict[str, Any]: + if not bench: + return { + "days": 0, "mine_pct": None, "bench_pct": None, + "gap_pct": None, "capture": None, + } + ms, bs = _compound(mine), _compound(bench) + return { + "days": len(bench), + "mine_pct": ms, + "bench_pct": bs, + "gap_pct": ms - bs, + "capture": (ms / bs) if bs != 0 else None, + } + + return {"up": _agg(up_mine, up_bench), "down": _agg(down_mine, down_bench)} + + +def format_regime_line(regime: dict[str, dict[str, Any]]) -> str: + """국면 분해를 한 줄 요약으로. 값이 없으면 그 국면은 생략한다.""" + parts: list[str] = [] + # 포착률을 앞세운다 — 이게 행동을 바꾸는 숫자다. 상승 포착이 낮으면 '방어의 대가'가 + # 크다는 뜻이고, 하락 포착이 높으면 방어가 실제로 작동하지 않는다는 뜻이다. + for key, label, verb in ( + ("up", "상승", "따라감"), + ("down", "하락", "맞음"), + ): + r = regime.get(key) or {} + if not r.get("days"): + continue + cap = r.get("capture") + if cap is None: + parts.append(f"{label} {r['days']}일: 지수 무변동") + continue + parts.append( + f"{label} {r['days']}일 포착 {cap * 100:.0f}% " + f"(지수 {r['bench_pct']:+.1f}% 중 {r['mine_pct']:+.1f}%만 {verb})" + ) + return " · ".join(parts) if parts else "국면 분해 불가(표본 부족)" + + +def format_risk_line(metrics: dict[str, Any]) -> str: + """리스크 지표를 한 줄 요약으로. 표본 부족이면 그렇다고 말한다.""" + n = metrics.get("samples") or 0 + if n < 2: + return f"표본 {n}일 — 변동성 산출 불가" + parts = [f"연변동성 {metrics['vol_annual_pct']:.1f}%"] + if metrics.get("sharpe_annual") is not None: + parts.append(f"샤프 {metrics['sharpe_annual']:+.2f}") + if metrics.get("down_day_ratio") is not None: + parts.append(f"하락일 {metrics['down_day_ratio'] * 100:.0f}%") + if metrics.get("worst_day_pct") is not None: + parts.append(f"최악일 {metrics['worst_day_pct']:+.2f}%") + return " · ".join(parts) + f" (표본 {n}일)" diff --git a/core/portfolio_manager.py b/core/portfolio_manager.py index ebe0de05..0350db2d 100644 --- a/core/portfolio_manager.py +++ b/core/portfolio_manager.py @@ -4,6 +4,8 @@ - KIS 잔고와 DB 포지션 동기화 (sync_with_broker) """ +import math + from loguru import logger from config.config_loader import Config @@ -18,6 +20,7 @@ get_cash_flow_total_between, get_max_cumulative_return, get_latest_snapshot_summary, + get_snapshot_before, has_cash_flows, ) @@ -28,10 +31,27 @@ def twr_period_return(v_prev: float, v_now: float, flow: float = 0.0) -> float: r = v_now / (v_prev + flow) - 1 — 입금은 수익이 아니므로 분모에 더해 중화한다. 분모가 0 이하이면 판정 불가로 0을 반환한다(신규 계정 초기 상태 등). """ - base = float(v_prev) + float(flow) + values = (float(v_prev), float(v_now), float(flow)) + if not all(math.isfinite(value) for value in values): + raise ValueError("TWR 입력값은 유한한 숫자여야 합니다") + base = values[0] + values[2] if base <= 0: return 0.0 - return float(v_now) / base - 1.0 + return values[1] / base - 1.0 + + +def _finite_number(value, *, name: str, positive: bool = False) -> float: + """장부 계산에 NaN/Inf/불리언이 섞이지 않도록 공통 검증한다.""" + if isinstance(value, bool): + raise ValueError(f"{name}은 유한한 숫자여야 합니다") + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name}은 유한한 숫자여야 합니다") from exc + if not math.isfinite(number) or (positive and number <= 0): + requirement = "유한한 양수" if positive else "유한한 숫자" + raise ValueError(f"{name}은 {requirement}여야 합니다") + return number class LiveBrokerBalanceUnavailable(RuntimeError): @@ -79,10 +99,27 @@ def __init__(self, config: Config = None, account_key: str = "", initial_capital "position_sizing", {} ).get("initial_capital", 10000000) ) - self._is_live = self.config.trading.get("mode", "paper") == "live" + self.initial_capital = _finite_number( + self.initial_capital, + name="initial_capital", + positive=True, + ) + self._is_live = str(self.config.trading.get("mode", "paper")).lower() == "live" + # DB 장부는 실제 체결인 live와 그 외(paper/schedule)를 두 개로만 + # 격리한다. schedule을 별도 mode로 넘기면 paper 포지션이 안 보인다. + self._ledger_mode = "live" if self._is_live else "paper" # Peak value 복구: DB 스냅샷에서 이전 세션의 peak을 가져와 MDD 연속성 유지 - restored_peak = get_latest_peak_value(account_key=self.account_key) + restored_peak = get_latest_peak_value( + account_key=self.account_key, + mode=self._ledger_mode, + ) + if restored_peak is not None: + restored_peak = _finite_number( + restored_peak, + name="restored_peak", + positive=True, + ) if restored_peak is not None and restored_peak > self.initial_capital: self._peak_value = restored_peak logger.info("Peak value DB에서 복구: {:,.0f}원", restored_peak) @@ -97,9 +134,19 @@ def __init__(self, config: Config = None, account_key: str = "", initial_capital self.account_key or "default", ) - def _build_position_state(self, current_prices: dict = None) -> dict: + def _build_position_state( + self, + current_prices: dict = None, + *, + require_market_prices: bool = False, + ) -> dict: """보유 포지션과 평가손익 상태 계산.""" - positions = get_all_positions(account_key=self.account_key if self.account_key else None) + positions = get_all_positions( + account_key=self.account_key if self.account_key else None, + mode=self._ledger_mode, + ) + if current_prices is not None and not isinstance(current_prices, dict): + raise ValueError("current_prices는 종목별 가격 딕셔너리여야 합니다") current_prices = current_prices or {} invested = 0.0 @@ -107,19 +154,44 @@ def _build_position_state(self, current_prices: dict = None) -> dict: position_details = [] for pos in positions: - price = current_prices.get(pos.symbol, pos.avg_price) - pos_value = price * pos.quantity - pos_invested = pos.avg_price * pos.quantity + symbol = str(getattr(pos, "symbol", "") or "").strip() + if not symbol: + raise ValueError("빈 종목코드가 장부에 포함되어 있습니다") + avg_price = _finite_number( + getattr(pos, "avg_price", None), + name=f"{symbol} 평균단가", + positive=True, + ) + quantity_number = _finite_number( + getattr(pos, "quantity", None), + name=f"{symbol} 수량", + positive=True, + ) + if not quantity_number.is_integer(): + raise ValueError(f"{symbol} 수량은 정수여야 합니다") + quantity = int(quantity_number) + + if require_market_prices and symbol not in current_prices: + raise ValueError(f"{symbol} 시장가격이 없어 스냅샷을 저장할 수 없습니다") + raw_price = current_prices.get(symbol, avg_price) + price = _finite_number( + raw_price, + name=f"{symbol} 현재가", + positive=True, + ) + + pos_value = price * quantity + pos_invested = avg_price * quantity pnl = pos_value - pos_invested - pnl_rate = ((price / pos.avg_price) - 1) * 100 if pos.avg_price > 0 else 0 + pnl_rate = ((price / avg_price) - 1) * 100 invested += pos_invested current_value += pos_value position_details.append({ - "symbol": pos.symbol, - "quantity": pos.quantity, - "avg_price": pos.avg_price, + "symbol": symbol, + "quantity": quantity, + "avg_price": avg_price, "current_price": price, "invested": pos_invested, "current_value": pos_value, @@ -137,14 +209,23 @@ def _build_position_state(self, current_prices: dict = None) -> dict: def _get_db_financials(self, invested: float, current_value: float, mode: str) -> dict: """trade_history 기준 현금/실현손익/총 평가금 계산.""" + invested = _finite_number(invested, name="투자원금") + current_value = _finite_number(current_value, name="포지션 평가금") cash_summary = get_trade_cash_summary( mode=mode, account_key=self.account_key if self.account_key else None, ) # 외부 현금 흐름(입금/출금)은 현금에 더하되 손익에서는 제외한다 — # 입금은 수익이 아니다(적립식 지원, docs/POCKET_TRACK_PLAN.md §4). - deposits = get_cash_flow_total(account_key=self.account_key) - cash = self.initial_capital + deposits + cash_summary["cash_delta"] + deposits = _finite_number(get_cash_flow_total( + account_key=self.account_key, + mode=self._ledger_mode, + ), name="누적 현금흐름") + cash_delta = _finite_number( + cash_summary.get("cash_delta"), + name="거래 현금변동", + ) + cash = self.initial_capital + deposits + cash_delta total_value = cash + current_value realized_pnl = cash + invested - self.initial_capital - deposits unrealized_pnl = current_value - invested @@ -155,7 +236,12 @@ def _get_db_financials(self, invested: float, current_value: float, mode: str) - "unrealized_pnl": unrealized_pnl, } - def get_portfolio_summary(self, current_prices: dict = None) -> dict: + def get_portfolio_summary( + self, + current_prices: dict = None, + *, + require_market_prices: bool = False, + ) -> dict: """ 포트폴리오 현황 요약 @@ -165,7 +251,10 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict: Returns: 포트폴리오 요약 딕셔너리 """ - state = self._build_position_state(current_prices) + state = self._build_position_state( + current_prices, + require_market_prices=require_market_prices, + ) invested = state["invested"] current_value = state["current_value"] position_details = state["position_details"] @@ -185,8 +274,21 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict: account_no = self.config.get_account_no(self.account_key) balance = KISApi(account_no=account_no).get_balance() if balance and "total_value" in balance: - total_value = float(balance["total_value"]) - cash = float(balance.get("cash", total_value - current_value)) + candidate_total = float(balance["total_value"]) + candidate_cash = float( + balance.get("cash", candidate_total - current_value) + ) + if ( + not math.isfinite(candidate_total) + or not math.isfinite(candidate_cash) + or candidate_total < 0 + or candidate_cash < 0 + ): + raise ValueError( + "KIS 잔고에 NaN/Inf 또는 음수 평가금액이 포함됨" + ) + total_value = candidate_total + cash = candidate_cash broker_balance_ok = True broker_balance_source = "kis" else: @@ -196,7 +298,10 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict: broker_balance_error = str(e) logger.warning("KIS 잔고 조회 실패 — DB 기준으로 대체: {}", e) - deposits_total = get_cash_flow_total(account_key=self.account_key) + deposits_total = _finite_number(get_cash_flow_total( + account_key=self.account_key, + mode=self._ledger_mode, + ), name="누적 현금흐름") if cash is None or total_value is None: financials = self._get_db_financials( @@ -214,7 +319,10 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict: # 분기는 순합이 아니라 '흐름 존재 여부'로 — 순합 0(+100/-100)이어도 구간 # 수익률은 이미 흐름의 영향을 받았으므로 TWR 경로를 유지해야 한다. - account_has_flows = deposits_total != 0 or has_cash_flows(self.account_key) + account_has_flows = deposits_total != 0 or has_cash_flows( + self.account_key, + mode=self._ledger_mode, + ) if not account_has_flows: # 무입금 계정: 기존 산식 그대로 (하위 호환 — 결과 불변) @@ -228,7 +336,10 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict: # 직전 스냅샷과 이번 측정 사이 유입(flow)을 분모에 더해 중화하고, # 누적은 직전 스냅샷의 누적수익률에 구간 수익률을 연결한다. from datetime import datetime as _dt - prev = get_latest_snapshot_summary(account_key=self.account_key) + prev = get_latest_snapshot_summary( + account_key=self.account_key, + mode=self._ledger_mode, + ) if prev is None: # 첫 측정: 초기자본이 첫 유입, 그간의 입금 전액이 구간 유입 r = twr_period_return(self.initial_capital, total_value, deposits_total) @@ -237,21 +348,52 @@ def get_portfolio_summary(self, current_prices: dict = None) -> dict: # 경계는 실제 측정 시각(created_at) — date(자정 귀속)를 쓰면 스냅샷 # 이전의 같은 날 입금이 이중 산입된다. boundary = prev.get("created_at") or prev.get("date") - flow_since = get_cash_flow_total_between( + flow_since = _finite_number(get_cash_flow_total_between( self.account_key, boundary, _dt.now(), + mode=self._ledger_mode, + ), name="구간 현금흐름") + prev_total = _finite_number( + prev.get("total_value"), name="직전 스냅샷 총평가금" + ) + prev_return = _finite_number( + prev.get("cumulative_return"), name="직전 누적수익률" ) - r = twr_period_return(prev["total_value"], total_value, flow_since) - total_return = ((1 + prev["cumulative_return"] / 100) * (1 + r) - 1) * 100 + r = twr_period_return(prev_total, total_value, flow_since) + total_return = ((1 + prev_return / 100) * (1 + r) - 1) * 100 # MDD도 TWR 지수 기준 — 원화 피크로 재면 입금이 낙폭을 가짜 회복시킨다. index_now = 1 + total_return / 100 - hist_max = get_max_cumulative_return(account_key=self.account_key) - peak_index = max(1.0, index_now, 1 + (hist_max or 0.0) / 100) + hist_max = get_max_cumulative_return( + account_key=self.account_key, + mode=self._ledger_mode, + ) + hist_max = ( + 0.0 + if hist_max is None + else _finite_number(hist_max, name="과거 최대 누적수익률") + ) + peak_index = max(1.0, index_now, 1 + hist_max / 100) mdd = ((peak_index - index_now) / peak_index) * 100 if peak_index > 0 else 0 # 원화 피크(peak_value 컬럼)는 스냅샷 연속성 위해 기존대로 계속 기록 if total_value > self._peak_value: self._peak_value = total_value + calculated = { + "total_value": total_value, + "cash": cash, + "invested": invested, + "current_value": current_value, + "total_return": total_return, + "mdd": mdd, + "realized_pnl": realized_pnl, + "unrealized_pnl": unrealized_pnl, + "deposits_total": deposits_total, + "principal": self.initial_capital + deposits_total, + "peak_value": self._peak_value, + } + for name, value in calculated.items(): + _finite_number(value, name=name) + return { "total_value": round(total_value, 0), "cash": round(cash, 0), @@ -276,18 +418,41 @@ def save_daily_snapshot(self, current_prices: dict = None, snapshot_date=None) - snapshot_date: 귀속 날짜 지정(미지정 시 오늘). 비거래일 보충 실행에서 NAV의 가격 기준일(직전 거래일)로 귀속할 때 사용. """ - summary = self.get_portfolio_summary(current_prices) + try: + # 보유 종목이 있다면 모든 종목의 실제 시장가격이 있어야 한다. 평균단가 + # 폴백으로 손실을 숨긴 NAV를 증거 장부에 남기는 것은 허용하지 않는다. + summary = self.get_portfolio_summary( + current_prices, + require_market_prices=True, + ) + except (TypeError, ValueError) as exc: + logger.error( + "포트폴리오 스냅샷 저장 차단 (계좌: {}): {}", + self.account_key or "default", + exc, + ) + return False + + # 전일 대비 수익률: 종전에는 이 인자를 아예 안 넘겨 기본값 0이 그대로 저장됐다 + # (운영 스냅샷 61행이 전부 daily_return=0.0). 변동성·샤프·일일 손실 한도가 모두 + # 이 열을 보므로, 값이 0이면 그 계산들이 조용히 무력화된다. + # 입금은 수익이 아니므로 구간 유입을 중화한 TWR 구간 수익률로 계산한다. + daily_return = self._compute_daily_return( + summary["total_value"], snapshot_date, + ) ok = save_portfolio_snapshot( total_value=summary["total_value"], cash=summary["cash"], invested=summary["invested"], + daily_return=daily_return, cumulative_return=summary["total_return"], mdd=summary["mdd"], position_count=summary["position_count"], account_key=self.account_key, peak_value=self._peak_value, snapshot_date=snapshot_date, + mode=self._ledger_mode, ) if ok: @@ -302,6 +467,40 @@ def save_daily_snapshot(self, current_prices: dict = None, snapshot_date=None) - ) return bool(ok) + def _compute_daily_return(self, total_value: float, snapshot_date=None) -> float: + """직전 스냅샷 대비 구간 수익률(%). 직전 기록이 없으면 0. + + 구간 사이의 외부 현금흐름(입금·출금)은 수익이 아니므로 분모에서 중화한다 + (누적수익률과 같은 twr_period_return 산식). 계산 실패는 스냅샷 저장을 막지 + 않는다 — 값은 0으로 두고 경고만 남긴다(관측 지표가 원장을 인질로 잡지 않게). + """ + from datetime import datetime as _dt + + try: + base = snapshot_date or _dt.now() + prev = get_snapshot_before( + base, account_key=self.account_key, mode=self._ledger_mode, + ) + if prev is None: + return 0.0 + boundary = prev.get("created_at") or prev.get("date") + flow_since = _finite_number( + get_cash_flow_total_between( + self.account_key, boundary, _dt.now(), mode=self._ledger_mode, + ), + name="구간 현금흐름", + ) + prev_total = _finite_number( + prev.get("total_value"), name="직전 스냅샷 총평가금", + ) + return twr_period_return(prev_total, total_value, flow_since) * 100 + except Exception as exc: + logger.warning( + "전일 대비 수익률 계산 실패 (계좌: {}): {} — 0으로 기록", + self.account_key or "default", exc, + ) + return 0.0 + def get_paper_performance_report(self, days: int = 30) -> dict: """ Paper 모드 성과 요약 리포트. @@ -416,7 +615,13 @@ def _sync_with_broker_impl(self, auto_correct: bool) -> dict: return {"ok": False, "mismatches": [], "corrected": [], "message": "잔고 응답 없음"} kis_positions = {p["symbol"]: p for p in balance["positions"] if p.get("symbol")} - db_positions = {p.symbol: p for p in get_all_positions(account_key=self.account_key if self.account_key else None)} + db_positions = { + p.symbol: p + for p in get_all_positions( + account_key=self.account_key if self.account_key else None, + mode=self._ledger_mode, + ) + } empty_broker_auto_correct_skipped = False mismatches = [] @@ -548,6 +753,7 @@ def _auto_correct_positions(self, mismatches: list) -> list: quantity=int(m["kis_qty"]), strategy="broker_sync_recovered", account_key=ak, + mode=self._ledger_mode, **targets, ) corrected.append({ @@ -559,14 +765,14 @@ def _auto_correct_positions(self, mismatches: list) -> list: logger.info("자동 보정: {} DB에 추가 ({}주)", symbol, m["kis_qty"]) elif m["type"] == "db_only": - delete_position(symbol, account_key=ak) + delete_position(symbol, account_key=ak, mode=self._ledger_mode) corrected.append({"symbol": symbol, "action": "deleted", "qty": m["db_qty"]}) logger.info("자동 보정: {} DB에서 삭제 (KIS에 없음)", symbol) elif m["type"] == "qty_mismatch": kis_qty = int(m["kis_qty"]) if kis_qty == 0: - delete_position(symbol, account_key=ak) + delete_position(symbol, account_key=ak, mode=self._ledger_mode) corrected.append({"symbol": symbol, "action": "deleted", "qty": 0}) else: reference_price = self._broker_reference_price(m) @@ -579,6 +785,7 @@ def _auto_correct_positions(self, mismatches: list) -> list: quantity=kis_qty, strategy="broker_sync_recovered", account_key=ak, + mode=self._ledger_mode, **targets, ) corrected.append({ diff --git a/core/risk_manager.py b/core/risk_manager.py index afde1801..8730c124 100644 --- a/core/risk_manager.py +++ b/core/risk_manager.py @@ -91,18 +91,52 @@ def calculate_position_size( Returns: 매수 가능 수량 """ - if entry_price <= 0: + if ( + not isinstance(entry_price, (int, float, np.integer, np.floating)) + or isinstance(entry_price, (bool, np.bool_)) + or not np.isfinite(entry_price) + or entry_price <= 0 + ): logger.warning("진입가가 0 이하 — 포지션 계산 불가 (entry_price={})", entry_price) return 0 - if capital <= 0: + if ( + not isinstance(capital, (int, float, np.integer, np.floating)) + or isinstance(capital, (bool, np.bool_)) + or not np.isfinite(capital) + or capital <= 0 + ): logger.warning("자본이 0 이하 — 포지션 계산 불가") return 0 + if ( + not isinstance(stop_loss_price, (int, float, np.integer, np.floating)) + or isinstance(stop_loss_price, (bool, np.bool_)) + or not np.isfinite(stop_loss_price) + or stop_loss_price <= 0 + or stop_loss_price >= entry_price + ): + logger.warning( + "손절가는 진입가보다 낮은 양수여야 함 — 포지션 계산 불가 " + "(entry_price={}, stop_loss_price={})", + entry_price, + stop_loss_price, + ) + return 0 + max_risk = self.risk_params.get("position_sizing", {}).get("max_risk_per_trade", 0.01) + if ( + not isinstance(max_risk, (int, float)) + or isinstance(max_risk, bool) + or not np.isfinite(max_risk) + or max_risk <= 0 + or max_risk > 0.05 + ): + logger.error("max_risk_per_trade 설정 오류 — 신규 포지션 차단: {}", max_risk) + return 0 risk_amount = capital * max_risk - risk_per_share = abs(entry_price - stop_loss_price) + risk_per_share = entry_price - stop_loss_price if risk_per_share <= 0: logger.warning("손절 폭이 0 이하 — 포지션 계산 불가") return 0 @@ -115,7 +149,8 @@ def calculate_position_size( ) return 0 - quantity = int(risk_amount / risk_per_share) + risk_capped_quantity = int(risk_amount / risk_per_share) + quantity = risk_capped_quantity # 신호 강도 기반 스케일링 scale = self._signal_scale(signal_score) @@ -123,6 +158,10 @@ def calculate_position_size( quantity = int(quantity * scale) logger.debug("신호 강도 스케일링: score={} → scale={:.2f}", signal_score, scale) + # max_risk_per_trade는 이름 그대로 절대 상한이다. 신호 강도는 약한 신호의 + # 수량을 줄일 수만 있고, 강한 신호가 이 손실 예산을 확대해서는 안 된다. + quantity = min(quantity, risk_capped_quantity) + max_ratio = self.risk_params.get("diversification", {}).get("max_position_ratio", 0.20) max_invest = capital * max_ratio max_by_ratio = int(max_invest / entry_price) @@ -134,7 +173,7 @@ def calculate_position_size( "포지션 계산: 자본={:,.0f} | 진입가={:,.0f} | 손절가={:,.0f} | " "1% 룰={}주 | 비중제한={}주 | 신호스케일={:.2f} | 최종={}주", capital, entry_price, stop_loss_price, - quantity, max_by_ratio, scale, final_qty, + risk_capped_quantity, max_by_ratio, min(scale, 1.0), final_qty, ) return final_qty @@ -175,14 +214,17 @@ def check_correlation_risk( {"scale": float, "high_corr_symbols": list, "reason": str} """ corr_cfg = self.risk_params.get("diversification", {}).get("correlation_risk", {}) - if not corr_cfg.get("enabled", False) or not existing_symbols: + enabled = corr_cfg.get("enabled", False) + # 자기 자신은 비교 대상에서 뺀다. 보유 중인 종목을 추가 매수하면 호출부가 넘기는 + # existing_symbols에 대상 종목이 그대로 들어 있어 corr(x, x)=1.0이 잡히고, 이는 + # 항상 임계값을 넘는다 — '분산' 판단으로는 무의미한데 결과는 매번 고상관 판정이다. + # 실측 피해: 바스켓이 만드는 주문은 대부분 보유분 추가매수라, 1일차 체결 이후 + # 모든 추가매수가 영구 거부돼 트랙이 57거래일간 통째로 얼어붙었다(2026-06-10~08-06). + # 동일 종목의 추가 노출은 max_position_ratio(단일 종목 상한)가 이미 관리한다. + existing_symbols = [s for s in (existing_symbols or []) if str(s) != str(symbol)] + if enabled is False or enabled is None or not existing_symbols: return {"scale": 1.0, "high_corr_symbols": [], "reason": ""} - threshold = float(corr_cfg.get("high_corr_threshold", 0.7)) - scale_factor = float(corr_cfg.get("high_corr_scale", 0.5)) - strict = bool(corr_cfg.get("strict", True)) - lb = lookback_days or int(corr_cfg.get("lookback_days", 60)) - def _blocked(reason: str, symbols: list[str] | None = None) -> dict: payload = { "scale": 0.0, @@ -194,6 +236,37 @@ def _blocked(reason: str, symbols: list[str] | None = None) -> dict: payload["missing_symbols"] = symbols return payload + if enabled is not True: + return _blocked("상관관계 리스크 설정 오류: enabled는 boolean이어야 함") + strict_raw = corr_cfg.get("strict", True) + if not isinstance(strict_raw, bool): + return _blocked("상관관계 리스크 설정 오류: strict는 boolean이어야 함") + strict = strict_raw + try: + threshold = float(corr_cfg.get("high_corr_threshold", 0.7)) + scale_factor = float(corr_cfg.get("high_corr_scale", 0.5)) + raw_lookback = ( + lookback_days + if lookback_days is not None + else corr_cfg.get("lookback_days", 60) + ) + if isinstance(raw_lookback, bool): + raise ValueError("boolean lookback") + lb = int(raw_lookback) + except (TypeError, ValueError, OverflowError) as exc: + return _blocked(f"상관관계 리스크 설정 오류: {exc}") + if ( + not np.isfinite(threshold) + or not np.isfinite(scale_factor) + or not (0 < threshold <= 1) + or not (0 < scale_factor <= 1) + or lb < 30 + ): + return _blocked( + "상관관계 리스크 설정 오류: threshold/scale은 (0,1], " + "lookback_days는 30 이상이어야 함" + ) + try: from core.data_collector import DataCollector collector = DataCollector() @@ -293,14 +366,40 @@ def calculate_stop_loss( Returns: 손절 가격 """ + try: + entry_price = float(entry_price) + regime_multiplier = float(regime_multiplier) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("손절가 계산 입력은 유한한 숫자여야 합니다") from exc + if not np.isfinite(entry_price) or entry_price <= 0: + raise ValueError(f"entry_price는 유한한 양수여야 합니다: {entry_price!r}") + if not np.isfinite(regime_multiplier) or regime_multiplier <= 0: + raise ValueError( + f"regime_multiplier는 유한한 양수여야 합니다: {regime_multiplier!r}" + ) + sl_config = self.risk_params.get("stop_loss", {}) sl_type = sl_config.get("type", "fixed") if sl_type == "atr" and atr is not None: - multiplier = sl_config.get("atr_multiplier", 2.0) + try: + atr = float(atr) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("ATR은 유한한 양수여야 합니다") from exc + if not np.isfinite(atr) or atr <= 0: + raise ValueError(f"ATR은 유한한 양수여야 합니다: {atr!r}") + multiplier = float(sl_config.get("atr_multiplier", 2.0)) + if not np.isfinite(multiplier) or multiplier <= 0: + raise ValueError( + f"atr_multiplier는 유한한 양수여야 합니다: {multiplier!r}" + ) stop_price = entry_price - (atr * multiplier * regime_multiplier) else: - fixed_rate = sl_config.get("fixed_rate", 0.03) * regime_multiplier + fixed_rate = float(sl_config.get("fixed_rate", 0.03)) * regime_multiplier + if not np.isfinite(fixed_rate) or fixed_rate <= 0: + raise ValueError( + f"fixed stop-loss rate는 유한한 양수여야 합니다: {fixed_rate!r}" + ) stop_price = entry_price * (1 - fixed_rate) stop_price = round(stop_price, 0) @@ -413,6 +512,15 @@ def check_mdd(self, current_value: float) -> dict: "peak": 최고점, } """ + try: + current_value = float(current_value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("MDD 평가금액은 유한한 양수여야 합니다") from exc + if not np.isfinite(current_value) or current_value <= 0: + raise ValueError( + f"MDD 평가금액은 유한한 양수여야 합니다: {current_value!r}" + ) + # 최고점 갱신 if current_value > self._peak_value: self._peak_value = current_value @@ -462,7 +570,16 @@ def check_daily_loss(self, daily_pnl: float, capital: float) -> bool: Returns: True이면 매매 계속, False이면 중단 """ - if capital <= 0: + try: + daily_pnl = float(daily_pnl) + capital = float(capital) + except (TypeError, ValueError, OverflowError): + return False + if ( + not np.isfinite(daily_pnl) + or not np.isfinite(capital) + or capital <= 0 + ): return False max_daily = self.risk_params.get("drawdown", {}).get("max_daily_loss", 0.03) @@ -485,6 +602,120 @@ def check_daily_loss(self, daily_pnl: float, capital: float) -> bool: # 분산 투자 체크 # ============================================================= + def check_projected_exposure( + self, + *, + current_positions: int, + position_value: float, + total_value: float, + available_cash: float | None, + current_invested: float, + existing_position_value: float = 0, + is_new_position: bool = True, + symbol: str = "", + ) -> dict: + """모든 BUY 경로가 공유하는 숫자 기반 최종 노출 상한 검사.""" + div_config = self.risk_params.get("diversification", {}) + try: + raw_max_positions = div_config.get("max_positions", 10) + if isinstance(raw_max_positions, bool): + raise ValueError("boolean max_positions") + max_positions = int(raw_max_positions) + max_ratio = float(div_config.get("max_position_ratio", 0.20)) + max_investment_ratio = float( + div_config.get("max_investment_ratio", 0.70) + ) + min_cash = float(div_config.get("min_cash_ratio", 0.20)) + current_positions = int(current_positions) + position_value = self._value_in_krw_for_symbol( + symbol, float(position_value) + ) + existing_position_value = self._value_in_krw_for_symbol( + symbol, float(existing_position_value) + ) + total_value = float(total_value) + current_invested = float(current_invested) + available_cash = ( + None if available_cash is None else float(available_cash) + ) + except (TypeError, ValueError, OverflowError) as exc: + return { + "can_buy": False, + "reason": f"노출 한도 입력/설정 오류: {exc}", + "exposure_check_failed": True, + } + + numeric_values = ( + max_ratio, + max_investment_ratio, + min_cash, + position_value, + existing_position_value, + total_value, + current_invested, + ) + if available_cash is not None: + numeric_values += (available_cash,) + if ( + not all(np.isfinite(value) for value in numeric_values) + or max_positions <= 0 + or current_positions < 0 + or not (0 < max_ratio <= 1) + or not (0 < max_investment_ratio <= 1) + or not (0 <= min_cash < 1) + or position_value <= 0 + or existing_position_value < 0 + or total_value <= 0 + or current_invested < 0 + or available_cash is not None and available_cash < 0 + ): + return { + "can_buy": False, + "reason": "노출 한도 입력/설정에 NaN/Inf·음수 또는 잘못된 비율이 포함됨", + "exposure_check_failed": True, + } + + projected_position_value = existing_position_value + position_value + projected_invested = current_invested + position_value + if is_new_position and current_positions >= max_positions: + return { + "can_buy": False, + "reason": f"최대 보유 종목({max_positions}개) 초과", + } + if projected_position_value / total_value > max_ratio: + return { + "can_buy": False, + "reason": f"단일 종목 비중 {max_ratio*100:.0f}% 초과", + } + if projected_invested / total_value > max_investment_ratio: + return { + "can_buy": False, + "reason": f"전체 투자 비중 {max_investment_ratio*100:.0f}% 초과", + } + + # 현금이 명시되지 않아도 총자산-현재투자액으로 보수적으로 추정한다. + cash_before_order = ( + available_cash + if available_cash is not None + else max(0.0, total_value - current_invested) + ) + remaining_cash_ratio = (cash_before_order - position_value) / total_value + if remaining_cash_ratio < min_cash: + return { + "can_buy": False, + "reason": f"최소 현금 비중 {min_cash*100:.0f}% 미만", + } + + return { + "can_buy": True, + "reason": "", + "position_value": position_value, + "existing_position_value": existing_position_value, + "projected_position_value": projected_position_value, + "projected_invested": projected_invested, + "remaining_cash_ratio": remaining_cash_ratio, + } + def check_diversification( self, current_positions: int, @@ -495,61 +726,69 @@ def check_diversification( symbol: str = "", sector_map: dict | None = None, positions: list | None = None, + existing_position_value: float = 0, + is_new_position: bool = True, ) -> dict: """ 분산 투자 규칙 확인 (종목 수·비중·투자비율·현금 + 업종 비중) Args: current_positions: 현재 보유 종목 수 - position_value: 해당 종목 투자 금액 + position_value: 이번 주문으로 추가될 투자 금액 total_value: 총 포트폴리오 가치 available_cash: 가용 현금 current_invested: 현재 총 투자 금액 symbol: 매수 대상 종목코드 (업종 체크용) sector_map: {종목코드: 업종명} 딕셔너리 positions: 현재 보유 Position 객체 리스트 (업종 비중 계산용) + existing_position_value: 동일 종목의 기존 투자 금액 + is_new_position: 이번 주문이 신규 종목 추가인지 여부 Returns: {"can_buy": bool, "reason": str} """ div_config = self.risk_params.get("diversification", {}) - max_positions = div_config.get("max_positions", 10) - max_ratio = div_config.get("max_position_ratio", 0.20) - max_investment_ratio = div_config.get("max_investment_ratio", 0.70) - min_cash = div_config.get("min_cash_ratio", 0.20) - sector_map_strict = bool(div_config.get("sector_map_strict", True)) + sector_map_strict = div_config.get("sector_map_strict", True) + if not isinstance(sector_map_strict, bool): + return { + "can_buy": False, + "reason": "업종 비중 설정 오류: sector_map_strict는 boolean이어야 함", + } - position_value = self._value_in_krw_for_symbol(symbol, float(position_value or 0)) + position_value = float(position_value or 0) + existing_position_value = float(existing_position_value or 0) current_invested = float(current_invested or 0) + exposure = self.check_projected_exposure( + current_positions=current_positions, + position_value=position_value, + total_value=total_value, + available_cash=available_cash, + current_invested=current_invested, + existing_position_value=existing_position_value, + is_new_position=is_new_position, + symbol=symbol, + ) + if not exposure["can_buy"]: + return exposure + position_value = float(exposure["position_value"]) - if current_positions >= max_positions: - return {"can_buy": False, "reason": f"최대 보유 종목({max_positions}개) 초과"} - - if total_value > 0 and (position_value / total_value) > max_ratio: - return {"can_buy": False, "reason": f"단일 종목 비중 {max_ratio*100:.0f}% 초과"} - - if total_value > 0: - projected_invested_ratio = (current_invested + position_value) / total_value - if projected_invested_ratio > max_investment_ratio: + # 업종별 최대 비중 체크 + max_sector_ratio = div_config.get("max_sector_ratio") + if max_sector_ratio is not None: + try: + max_sector_ratio = float(max_sector_ratio) + except (TypeError, ValueError, OverflowError): return { "can_buy": False, - "reason": f"전체 투자 비중 {max_investment_ratio*100:.0f}% 초과", + "reason": "업종 비중 설정 오류: max_sector_ratio가 숫자가 아님", } - - if available_cash is not None and total_value > 0: - remaining_cash = available_cash - position_value - remaining_cash_ratio = remaining_cash / total_value - if remaining_cash_ratio < min_cash: + if not np.isfinite(max_sector_ratio) or not (0 < max_sector_ratio <= 1): return { "can_buy": False, - "reason": f"최소 현금 비중 {min_cash*100:.0f}% 미만", + "reason": "업종 비중 설정 오류: max_sector_ratio는 (0,1]이어야 함", } - - # 업종별 최대 비중 체크 - max_sector_ratio = div_config.get("max_sector_ratio") if ( max_sector_ratio is not None - and max_sector_ratio > 0 and total_value > 0 and symbol and positions is not None @@ -624,11 +863,41 @@ def check_recent_performance(self, recent_sell_trades: list) -> dict: {"allowed": 매수 허용 여부, "win_rate": 승률(0~1), "reason": 사유} """ cfg = self.risk_params.get("performance_degradation", {}) - if not cfg.get("enabled", False): + enabled = cfg.get("enabled", False) + if enabled is False or enabled is None: return {"allowed": True, "win_rate": None, "reason": ""} - - min_win_rate = float(cfg.get("min_win_rate", 0.35)) - min_sample = max(5, int(cfg.get("recent_trades", 20)) // 2) + if enabled is not True: + return { + "allowed": False, + "win_rate": None, + "reason": "성과 열화 설정 오류: enabled는 boolean이어야 함", + "performance_config_invalid": True, + } + try: + min_win_rate = float(cfg.get("min_win_rate", 0.35)) + raw_recent_trades = cfg.get("recent_trades", 20) + if isinstance(raw_recent_trades, bool): + raise ValueError("boolean recent_trades") + recent_trades = int(raw_recent_trades) + except (TypeError, ValueError, OverflowError) as exc: + return { + "allowed": False, + "win_rate": None, + "reason": f"성과 열화 설정 오류: {exc}", + "performance_config_invalid": True, + } + if ( + not np.isfinite(min_win_rate) + or not (0 < min_win_rate <= 1) + or recent_trades < 5 + ): + return { + "allowed": False, + "win_rate": None, + "reason": "성과 열화 설정 오류: min_win_rate 또는 recent_trades 범위 오류", + "performance_config_invalid": True, + } + min_sample = max(5, recent_trades // 2) if not recent_sell_trades or len(recent_sell_trades) < min_sample: return {"allowed": True, "win_rate": None, "reason": ""} @@ -670,7 +939,7 @@ def calculate_transaction_costs( symbol: str = None, ) -> dict: """ - 거래 비용 계산 (수수료 + 증권거래세 + 슬리피지 + 양도소득세(선택)) + 거래 비용 계산 (수수료 + 매도 관련 세금 + 슬리피지 + 양도소득세(선택)) Args: price: 체결 가격 @@ -679,14 +948,50 @@ def calculate_transaction_costs( avg_daily_volume: 일평균 거래량 (동적 슬리피지용) avg_price: 매도 시 평균 매입 단가 (양도소득세 계산용; 대주주 해당 시) symbol: 종목코드 (선택). transaction_costs.tax_exempt_symbols에 있으면 - 매도세를 면제한다 — 국내 상장 ETF는 증권거래세 비과세인데 개별 주식 - 세율을 일괄 적용하면 ETF 바스켓의 비용이 과대계상된다(승격 게이트의 - 비용 상한 판정까지 왜곡). 미전달 시 기존 동작(일괄 과세) 유지. + 증권거래세를 면제한다. 다만 holding_period_income_tax.symbols에 등록된 + 기타 ETF는 양(+)의 매매차익에 보유기간 과세를 별도로 반영한다. + 미전달 시 기존 동작(일괄 거래세 과세) 유지. Returns: - commission(수수료), tax(증권거래세+농특세 매도 시 0.20%), capital_gains_tax(양도소득세, 설정 시), - slippage, total_cost, effective_price 등. + commission(수수료), transaction_tax(증권거래세+농특세), + holding_period_income_tax(기타 ETF 보유기간 과세), tax(두 세금 합계), + capital_gains_tax(양도소득세, 설정 시), slippage, total_cost, + effective_price 등. """ + if ( + not isinstance(price, (int, float, np.integer, np.floating)) + or isinstance(price, (bool, np.bool_)) + or not np.isfinite(price) + or price <= 0 + ): + raise ValueError(f"price는 유한한 양수여야 합니다: {price!r}") + if ( + not isinstance(quantity, (int, float, np.integer, np.floating)) + or isinstance(quantity, (bool, np.bool_)) + or not np.isfinite(quantity) + or quantity <= 0 + ): + raise ValueError(f"quantity는 유한한 양수여야 합니다: {quantity!r}") + action = str(action).upper() + if action not in {"BUY", "SELL"}: + raise ValueError(f"action은 BUY 또는 SELL이어야 합니다: {action!r}") + if avg_daily_volume is not None and ( + not isinstance(avg_daily_volume, (int, float, np.integer, np.floating)) + or isinstance(avg_daily_volume, (bool, np.bool_)) + or not np.isfinite(avg_daily_volume) + or avg_daily_volume < 0 + ): + raise ValueError( + f"avg_daily_volume은 0 이상의 유한한 수여야 합니다: {avg_daily_volume!r}" + ) + if avg_price is not None and ( + not isinstance(avg_price, (int, float, np.integer, np.floating)) + or isinstance(avg_price, (bool, np.bool_)) + or not np.isfinite(avg_price) + or avg_price <= 0 + ): + raise ValueError(f"avg_price는 유한한 양수여야 합니다: {avg_price!r}") + costs = self.risk_params.get("transaction_costs", {}) amount = price * quantity @@ -724,13 +1029,36 @@ def calculate_transaction_costs( slippage_rate_effective = slippage_per_share / price if price > 0 else 0 # 증권거래세+농특세: 매도 금액의 0.20% (2026년~ 코스피·코스닥 동일; ETF는 면제) - tax = 0 - if action.upper() == "SELL": - tax = amount * sell_tax_rate + transaction_tax = 0.0 + if action == "SELL": + transaction_tax = amount * sell_tax_rate + + # 국내주식형이 아닌 ETF의 보유기간 과세. 법정 과세표준은 + # min(양의 매매차익, 양의 과표기준가격 증분)이지만 일별 과표기준가를 현재 + # 데이터 파이프라인이 제공하지 않는다. 등록 종목은 양의 매매차익 전액을 + # 과세표준으로 잡아 세후 성과를 낙관하지 않는 보수적 상한을 사용한다. + holding_period_income_tax = 0.0 + holding_tax_cfg = costs.get("holding_period_income_tax", {}) or {} + holding_tax_symbols = { + str(s) for s in (holding_tax_cfg.get("symbols") or []) + } + if ( + action == "SELL" + and holding_tax_cfg.get("enabled", False) + and symbol is not None + and str(symbol) in holding_tax_symbols + and avg_price is not None + ): + taxable_gain = max(0.0, (price - avg_price) * quantity) + holding_period_income_tax = taxable_gain * float( + holding_tax_cfg.get("rate", 0.154) + ) + + tax = transaction_tax + holding_period_income_tax # 양도소득세 (대주주 해당 시만; enabled 시 실현 이익에 대해 부과) capital_gains_tax = 0 - if action.upper() == "SELL" and avg_price is not None and quantity > 0: + if action == "SELL" and avg_price is not None and quantity > 0: cgt_cfg = costs.get("capital_gains_tax", {}) or {} if cgt_cfg.get("enabled", False): gain = (price - avg_price) * quantity @@ -740,18 +1068,22 @@ def calculate_transaction_costs( total_cost = commission + tax + slippage + capital_gains_tax # 실효 가격 (매수 시 높게, 매도 시 낮게; 증권거래세·슬리피지 반영, 양도소득세는 별도) - if action.upper() == "BUY": + if action == "BUY": effective_price = price * (1 + costs.get("commission_rate", 0) + slippage_rate_effective) execution_price = price + slippage_per_share else: - effective_price = price * ( - 1 - costs.get("commission_rate", 0) - slippage_rate_effective - sell_tax_rate + effective_price = ( + (amount - commission - slippage - tax - capital_gains_tax) / quantity + if quantity > 0 + else 0 ) execution_price = max(0, price - slippage_per_share) return { "commission": round(commission, 0), "tax": round(tax, 0), + "transaction_tax": round(transaction_tax, 0), + "holding_period_income_tax": round(holding_period_income_tax, 0), "capital_gains_tax": round(capital_gains_tax, 0), "slippage": round(slippage, 0), "total_cost": round(total_cost, 0), diff --git a/core/runtime_lock.py b/core/runtime_lock.py index 5208cfeb..a89fb528 100644 --- a/core/runtime_lock.py +++ b/core/runtime_lock.py @@ -1,10 +1,14 @@ -""" -장시간 프로세스(스케줄러) 단일 인스턴스 보장. -Linux/서버: fcntl flock. Windows: 락 생략(개발용) — Oracle/Ubuntu 배포 경로를 우선한다. +"""Cross-platform single-process runtime locks. + +The lock file is only metadata. Ownership is enforced by the operating +system, so a stale file left after a crash does not keep the runtime locked. +Linux/macOS use ``flock`` and Windows uses ``msvcrt.locking``. If neither +backend is available, or the lock cannot be checked, acquisition fails closed. """ from __future__ import annotations +import errno import os from contextlib import contextmanager from pathlib import Path @@ -12,60 +16,173 @@ from loguru import logger -try: +try: # POSIX import fcntl +except ImportError: # pragma: no cover - exercised on Windows + fcntl = None + +try: # Windows + import msvcrt +except ImportError: # pragma: no cover - exercised on POSIX + msvcrt = None + + +LIVE_RUNTIME_LOCK_FILENAME = ".live_runtime.lock" + - _HAS_FCNTL = True -except ImportError: - _HAS_FCNTL = False +def _write_owner_metadata(fp, label: str) -> None: + """Write diagnostics without changing the byte used by Windows locking.""" + metadata = f"pid={os.getpid()}\nlabel={label}\n".encode("utf-8", errors="replace") + fp.seek(1) + fp.truncate() + fp.write(metadata) + fp.flush() + os.fsync(fp.fileno()) + + +def _acquire_nonblocking(fp) -> str: + """Acquire an OS lock and return the backend name.""" + if fcntl is not None: + fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return "fcntl" + if msvcrt is not None: + fp.seek(0) + msvcrt.locking(fp.fileno(), msvcrt.LK_NBLCK, 1) + return "msvcrt" + raise RuntimeError("지원되는 프로세스 락 백엔드가 없습니다") + + +def _release(fp, backend: str) -> None: + if backend == "fcntl": + fcntl.flock(fp.fileno(), fcntl.LOCK_UN) + elif backend == "msvcrt": + fp.seek(0) + msvcrt.locking(fp.fileno(), msvcrt.LK_UNLCK, 1) @contextmanager -def scheduler_lock(lock_path: str | Path) -> Generator[bool, None, None]: - """ - 스케줄러 중복 기동 방지. 획득 실패 시 yield False. +def process_runtime_lock( + lock_path: str | Path, + *, + label: str = "runtime", +) -> Generator[bool, None, None]: + """Try to hold one non-blocking inter-process lock for the context. + + ``True`` means the caller exclusively owns the runtime. ``False`` means + another process owns it *or* exclusivity could not be proved. Callers that + can submit orders must treat both cases as a hard stop. """ path = Path(lock_path) - path.parent.mkdir(parents=True, exist_ok=True) - - if not _HAS_FCNTL: - logger.warning( - "fcntl 미지원 OS — 스케줄러 단일 인스턴스 락을 건너뜁니다. " - "프로덕션은 Linux에서 실행하세요." + fp = None + try: + path.parent.mkdir(parents=True, exist_ok=True) + fp = open(path, "a+b") + # Windows byte-range locks need a real byte to lock. It remains a + # permanent sentinel; owner metadata starts at byte 1. + fp.seek(0, os.SEEK_END) + if fp.tell() == 0: + fp.write(b"\0") + fp.flush() + os.fsync(fp.fileno()) + except Exception as exc: + logger.error( + "{} 락 파일 준비 실패 — 단일 인스턴스를 증명할 수 없어 실행 차단: {} ({})", + label, + path, + exc, ) - yield True + if fp is not None: + fp.close() + yield False return - fp = open(path, "a+", encoding="utf-8") + backend = "" + acquired = False try: try: - fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + backend = _acquire_nonblocking(fp) + acquired = True except BlockingIOError: logger.error( - "스케줄러가 이미 실행 중입니다 (락 파일: {}). 중복 기동을 중단합니다.", + "{}가 이미 실행 중입니다 (락 파일: {}). 중복 기동을 차단합니다.", + label, path, ) - fp.close() + yield False + return + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EAGAIN}: + logger.error( + "{}가 이미 실행 중입니다 (락 파일: {}). 중복 기동을 차단합니다.", + label, + path, + ) + else: + logger.error( + "{} 락 획득 실패 — 단일 인스턴스를 증명할 수 없어 실행 차단: {} ({})", + label, + path, + exc, + ) + yield False + return + except Exception as exc: + logger.error( + "{} 락 획득 실패 — 단일 인스턴스를 증명할 수 없어 실행 차단: {} ({})", + label, + path, + exc, + ) yield False return - fp.seek(0) - fp.truncate() - fp.write(str(os.getpid())) - fp.flush() - os.fsync(fp.fileno()) - logger.info("스케줄러 락 획득: {} (pid={})", path, os.getpid()) try: - yield True - finally: + _write_owner_metadata(fp, label) + except Exception as exc: + logger.error( + "{} 락 소유자 기록 실패 — 안전을 위해 실행 차단: {} ({})", + label, + path, + exc, + ) try: - fcntl.flock(fp.fileno(), fcntl.LOCK_UN) + _release(fp, backend) except OSError: pass - fp.close() - except Exception: + acquired = False + yield False + return + + logger.info( + "{} 락 획득: {} (pid={}, backend={})", + label, + path, + os.getpid(), + backend, + ) try: - fp.close() - except OSError: - pass - raise + yield True + finally: + if acquired: + try: + _release(fp, backend) + except OSError as exc: + # Closing the descriptor below still releases the OS lock. + logger.error("{} 락 명시적 해제 실패: {} ({})", label, path, exc) + finally: + fp.close() + + +@contextmanager +def scheduler_lock(lock_path: str | Path) -> Generator[bool, None, None]: + """Backward-compatible scheduler single-instance lock.""" + with process_runtime_lock(lock_path, label="스케줄러") as acquired: + yield acquired + + +@contextmanager +def live_runtime_lock(project_root: str | Path) -> Generator[bool, None, None]: + """Global lock shared by every non-emergency live order runtime.""" + lock_path = Path(project_root) / "data" / LIVE_RUNTIME_LOCK_FILENAME + with process_runtime_lock(lock_path, label="실전 주문 런타임") as acquired: + yield acquired diff --git a/core/scheduler.py b/core/scheduler.py index a506f21b..0a629d23 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -112,6 +112,22 @@ class Scheduler: MAX_CONSECUTIVE_SKIPS = 3 # 연속 스킵 허용 한도 (초과 시 알림) + def _resolved_ledger_mode(self) -> str: + """스케줄러 실행 모드를 paper/live 장부 모드로 정규화한다. + + 정상 인스턴스는 ``__init__``에서 값을 고정한다. 복구 도구나 최소 테스트 + 더블처럼 생성자가 생략된 객체도 설정 모드에서 안전하게 재해석한다. + live 이외의 값(schedule 포함)은 언제나 paper 장부다. + """ + mode = getattr(self, "_ledger_mode", None) + if mode in ("paper", "live"): + return mode + mode = getattr(self, "_mode", None) + if mode is None: + trading = getattr(getattr(self, "config", None), "trading", {}) or {} + mode = trading.get("mode", "paper") + return "live" if str(mode).lower() == "live" else "paper" + def __init__( self, strategy_name: str = "scoring", @@ -122,6 +138,7 @@ def __init__( self.config = config or Config.get() self.strategy_name = strategy_name self._mode = str(self.config.trading.get("mode", "paper")).lower() + self._ledger_mode = "live" if self._mode == "live" else "paper" self._live_gate_validated = bool(live_gate_validated) self.trading_hours = TradingHours(self.config) self.blackswan = BlackSwanDetector(self.config) @@ -188,6 +205,10 @@ def _get_or_create_executor(self): account_key=self.strategy_name, live_gate_validated=self._live_gate_validated, ) + # Scheduler와 OrderExecutor가 같은 cooldown/recovery 상태를 보도록 + # 단일 감지기를 공유한다. 별도 인스턴스면 Scheduler가 + # 급락을 감지해도 주문 직전 가드는 cooldown을 모른다. + self._order_executor.blackswan = self.blackswan return self._order_executor def _is_paper_like_mode(self) -> bool: @@ -458,7 +479,11 @@ def _run_pre_market(self): self.auto_entry and allow_buys and signal_info["signal"] == "BUY" - and not get_position(symbol) + and not get_position( + symbol, + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ) ): avg_vol = None if "volume" in df.columns and not df["volume"].empty: @@ -598,6 +623,12 @@ def _run_monitoring(self): if self.config.trading.get("mode") == "live": live_entry_allowed = self._maybe_sync_with_broker() + # 기존 노출의 손절/블랙스완을 신규 진입보다 먼저 점검한다. + # 급락 첫 사이클에서 선매수 후 손절하는 순서 역전을 막고, + # 여기서 활성화된 cooldown이 아래 진입 가드에 즉시 반영된다. + with PositionLock(): + self._check_exit_signals(kis=kis) + # 쿨다운 해제 직후 → 즉시 신호 재평가 (반등 구간 포착) if self.blackswan.consume_cooldown_ended_flag(): logger.info("블랙스완 쿨다운 해제 — 즉시 신호 재평가 트리거") @@ -612,8 +643,8 @@ def _run_monitoring(self): if self.auto_entry and self._entry_candidates and not self.blackswan.is_on_cooldown(): if live_entry_allowed: - # 신규 진입 실행 중 예기치 못한 예외가 나도 아래 손절/익절 점검은 - # 반드시 돌아야 하므로 진입 블록을 별도 try로 감싼다(안전 우선). + # 진입 블록 예외가 이후 동적 손절가 갱신/재스캔까지 + # 전파되지 않도록 별도 try로 감싼다. try: with PositionLock(): self._execute_entry_candidates() @@ -630,9 +661,6 @@ def _run_monitoring(self): self._last_broker_sync_message or "unknown", ) - with PositionLock(): - self._check_exit_signals(kis=kis) - # 장중 보유 종목 동적 손절가 업데이트 (ATR 변화 반영) self._update_dynamic_stop_losses() @@ -772,7 +800,11 @@ def _run_post_cooldown_rescan(self): if df.empty or len(df) < 30: continue signal_info = strategy.generate_signal(df, symbol=symbol) - if signal_info.get("signal") == "BUY" and not get_position(symbol, account_key=self.strategy_name): + if signal_info.get("signal") == "BUY" and not get_position( + symbol, + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ): avg_vol = None if "volume" in df.columns and not df["volume"].empty: avg_vol = float(df["volume"].rolling(20, min_periods=1).mean().iloc[-1]) @@ -948,7 +980,11 @@ def _execute_entry_candidates(self): candidates = list(self._entry_candidates) for idx, candidate in enumerate(candidates): symbol = candidate["symbol"] - if get_position(symbol, account_key=self.strategy_name): + if get_position( + symbol, + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ): continue # 오래된 후보 폐기 @@ -1064,14 +1100,17 @@ def _execute_entry_candidates(self): self._entry_candidates = remaining def _check_exit_signals(self, kis=None): - """포지션 순회: 갭다운 즉시 청산, 최대 보유 기간 초과 시 강제 정리, 블랙스완, 손절/익절/트레일링 스탑.""" + """포지션 순회: 블랙스완, 갭다운, 최대 보유 기간, 손절/익절/트레일링 스탑.""" from api.kis_api import KISApi executor = self._get_or_create_executor() account_no = self.config.get_account_no(self.strategy_name) if kis is None: kis = KISApi(account_no=account_no) - positions = get_all_positions(account_key=self.strategy_name) + positions = get_all_positions( + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ) today = datetime.now().date() max_holding_days = ( self.config.risk_params.get("position_limits", {}) or {} @@ -1118,13 +1157,54 @@ def _record_price_block(symbol: str, reason: str) -> None: except (TypeError, ValueError): prev_close = 0.0 - # 갭다운 즉시 청산: 전일 종가 대비 시가가 크게 갭다운이면 손절 회피 불가 - if gap_enabled and prev_close > 0: - gap_pct = (current_price - prev_close) / prev_close + # 심각한 급락은 gap 청산보다 먼저 판정해 cooldown을 반드시 + # 활성화한다. gap 분기가 continue하면 -5% 이하에서 BlackSwan + # 검사가 영원히 도달불가였던 순서 결함을 막는다. + bs_result = self.blackswan.check_stock( + pos.symbol, + current_price, + prev_close, + ) + if bs_result["triggered"]: + self.discord.send_message( + f"🚨 블랙스완 발동!\n{bs_result['reason']}", + critical=True, + ) + _log_op( + "BLACKSWAN", + bs_result["reason"], + severity="critical", + symbol=pos.symbol, + strategy=self.strategy_name, + mode=self._mode, + ) + executor.execute_sell( + pos.symbol, + current_price, + reason="블랙스완 긴급 매도", + strategy=self.strategy_name, + ) + continue + + # 갭다운은 '시가 vs 전일 종가'로만 판정한다. 현재가를 쓰면 + # 장중 -3% 하락도 갭다운으로 오인해 불필요한 긴급 청산을 낸다. + try: + open_price = float(price_info.get("open", 0) or 0) + except (TypeError, ValueError): + open_price = 0.0 + if ( + gap_enabled + and prev_close > 0 + and math.isfinite(open_price) + and open_price > 0 + ): + gap_pct = (open_price - prev_close) / prev_close if gap_pct <= gap_down_threshold: logger.warning( - "갭다운 청산 발동: {} 갭 {:.1f}% (기준 {:.0f}%)", - pos.symbol, gap_pct * 100, gap_down_threshold * 100, + "갭다운 청산 발동: {} 시가 갭 {:.1f}% (기준 {:.0f}%)", + pos.symbol, + gap_pct * 100, + gap_down_threshold * 100, ) result = executor.execute_sell( pos.symbol, current_price, @@ -1153,19 +1233,6 @@ def _record_price_block(symbol: str, reason: str) -> None: self.discord.send_trade_alert(result) continue - bs_result = self.blackswan.check_stock(pos.symbol, current_price, prev_close) - - if bs_result["triggered"]: - self.discord.send_message(f"🚨 블랙스완 발동!\n{bs_result['reason']}", critical=True) - _log_op("BLACKSWAN", bs_result["reason"], severity="critical", - symbol=pos.symbol, strategy=self.strategy_name, mode=self._mode) - executor.execute_sell( - pos.symbol, current_price, - reason="블랙스완 긴급 매도", - strategy=self.strategy_name, - ) - continue - check = executor.check_stop_loss_take_profit(pos.symbol, current_price) if check["action"]: _log_op("SL_TP", f"{check['action']} {pos.symbol} @ {current_price:,.0f}", @@ -1183,6 +1250,40 @@ def _record_price_block(symbol: str, reason: str) -> None: except Exception as e: logger.error("종목 {} 모니터링 실패: {}", pos.symbol, e) + def _collect_snapshot_prices(self) -> dict[str, float]: + """보유 종목 전부의 현재가를 수집한다. + + 한 종목이라도 가격을 확인하지 못하면 불완전한 NAV 스냅샷을 만들지 + 않도록 예외를 발생시킨다. + """ + positions = get_all_positions( + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ) + if not positions: + return {} + + from api.kis_api import KISApi + + account_no = self.config.get_account_no(self.strategy_name) + kis = KISApi(account_no=account_no) + prices: dict[str, float] = {} + for position in positions: + symbol = str(getattr(position, "symbol", "") or "").strip() + if not symbol: + raise RuntimeError("빈 종목코드가 포지션 장부에 포함되어 있습니다") + price_info = kis.get_current_price(symbol) + try: + price = float((price_info or {}).get("price")) + except (AttributeError, TypeError, ValueError): + price = 0.0 + if not math.isfinite(price) or price <= 0: + raise RuntimeError( + f"{symbol} 현재가를 확인하지 못해 장마감 스냅샷을 중단합니다" + ) + prices[symbol] = price + return prices + def _run_post_market(self): """장마감: 일일 리포트 저장 및 발송.""" logger.info("=" * 50) @@ -1190,8 +1291,10 @@ def _run_post_market(self): logger.info("=" * 50) try: - self.portfolio.save_daily_snapshot() - summary = self.portfolio.get_portfolio_summary() + snapshot_prices = self._collect_snapshot_prices() + if not self.portfolio.save_daily_snapshot(snapshot_prices): + raise RuntimeError("검증된 시장가격으로 포트폴리오 스냅샷을 저장하지 못했습니다") + summary = self.portfolio.get_portfolio_summary(snapshot_prices) trade_summary = get_daily_trade_summary( mode=self.config.trading.get("mode", "paper"), account_key=self.strategy_name, @@ -1646,7 +1749,10 @@ def _update_dynamic_stop_losses(self): from core.risk_manager import RiskManager from database.repositories import update_stop_loss_price - positions = get_all_positions(account_key=self.strategy_name) + positions = get_all_positions( + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ) if not positions: return @@ -1683,6 +1789,7 @@ def _update_dynamic_stop_losses(self): if new_sl > old_sl: update_stop_loss_price( pos.symbol, new_sl, account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), ) logger.debug( "동적 손절가 갱신: {} {:,.0f} → {:,.0f} (ATR={:.0f})", @@ -1752,7 +1859,11 @@ def _rescan_for_new_entries(self): watchlist = WatchlistManager(self.config).resolve() for symbol in watchlist: - if get_position(symbol, account_key=self.strategy_name): + if get_position( + symbol, + account_key=self.strategy_name, + mode=self._resolved_ledger_mode(), + ): continue if any(c["symbol"] == symbol for c in self._entry_candidates): continue diff --git a/core/weekly_report.py b/core/weekly_report.py index 89ea281a..3ce82715 100644 --- a/core/weekly_report.py +++ b/core/weekly_report.py @@ -22,6 +22,8 @@ def build_weekly_summary( week_nav_change_pct: float | None = None, missing_days: int = 0, cycle_errors: int = 0, + regime: dict[str, Any] | None = None, + risk: dict[str, Any] | None = None, ) -> dict[str, Any]: """주간 다이제스트를 만든다(순수 함수). @@ -69,6 +71,27 @@ def build_weekly_summary( "inline": False, }) + # 2-b) 국면 분해 — 방어적 포지션은 하락장에서 항상 좋아 보인다. 상승·하락을 + # 나눠 보고하지 않으면 '방어의 대가'(상승장 미스)가 통째로 숨는다. + # 실측(2026-08): 전체 +7.81%p 초과성과가 반등 구간 -8.97%p 미스를 덮고 있었다. + if regime: + from core.performance_lens import format_regime_line + fields.append({ + "name": "🌗 국면 분해", + "value": format_regime_line(regime), + "inline": False, + }) + + # 2-c) 리스크 지표 — daily_return이 복구되기 전(~2026-08-10)에는 스냅샷의 이 열이 + # 전부 0이라 변동성·샤프를 아예 계산할 수 없었다. 이제 표면화한다. + if risk: + from core.performance_lens import format_risk_line + fields.append({ + "name": "📉 리스크", + "value": format_risk_line(risk), + "inline": False, + }) + # 3) 일정 대비 — 진행률·커버리지 progress_days = eval_result.get("progress_days") if eval_result else None min_days = eval_result.get("min_trading_days") if eval_result else None diff --git a/database/models.py b/database/models.py index da2369f9..30f161ab 100644 --- a/database/models.py +++ b/database/models.py @@ -113,6 +113,9 @@ class Position(Base): __tablename__ = "positions" id = Column(Integer, primary_key=True, autoincrement=True) + mode = Column( + String(20), default="paper", server_default="paper", nullable=False, index=True + ) # paper / live (legacy migration rows may be quarantined as legacy) account_key = Column(String(64), default="", nullable=False, index=True) # 전략/계좌 구분 symbol = Column(String(20), nullable=False) # 종목 코드 avg_price = Column(Float, nullable=False) # 평균 매수가 @@ -127,10 +130,17 @@ class Position(Base): bought_at = Column(DateTime, default=datetime.now) # 최초 매수 시점 updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - __table_args__ = (UniqueConstraint("account_key", "symbol", name="uq_positions_account_symbol"),) + __table_args__ = ( + UniqueConstraint( + "mode", "account_key", "symbol", name="uq_positions_mode_account_symbol" + ), + ) def __repr__(self): - return f"" + return ( + f"" + ) @property def current_value(self): @@ -147,6 +157,9 @@ class PortfolioSnapshot(Base): __tablename__ = "portfolio_snapshots" id = Column(Integer, primary_key=True, autoincrement=True) + mode = Column( + String(20), default="paper", server_default="paper", nullable=False, index=True + ) # paper / live (legacy migration rows may be quarantined as legacy) account_key = Column(String(64), default="", nullable=False, index=True) date = Column(DateTime, nullable=False) # 기록 날짜 total_value = Column(Float, nullable=False) # 총 평가 금액 @@ -159,10 +172,17 @@ class PortfolioSnapshot(Base): position_count = Column(Integer, default=0) # 보유 종목 수 created_at = Column(DateTime, default=datetime.now) - __table_args__ = (UniqueConstraint("account_key", "date", name="uq_snapshots_account_date"),) + __table_args__ = ( + UniqueConstraint( + "mode", "account_key", "date", name="uq_snapshots_mode_account_date" + ), + ) def __repr__(self): - return f"" + return ( + f"" + ) class DailyReport(Base): @@ -233,6 +253,15 @@ class CashFlow(Base): TWR이 맞는다(증권사 잔고는 현금만 알지, 언제 얼마가 외부에서 왔는지는 모른다). """ __tablename__ = "cash_flows" + __table_args__ = ( + Index( + "uq_cash_flows_idempotency", + "mode", + "account_key", + "request_id", + unique=True, + ), + ) id = Column(Integer, primary_key=True, autoincrement=True) account_key = Column(String(64), default="", nullable=False, index=True) @@ -240,6 +269,9 @@ class CashFlow(Base): occurred_at = Column(DateTime, nullable=False, index=True) # 발생 시각(귀속 기준) note = Column(String(200), default="") # 메모 (예: 7월 적립) mode = Column(String(20), default="paper") + # 웹 요청 재시도 시 같은 입금이 두 번 기록되지 않도록 보존하는 고유 키. + # CLI 등 키가 없는 기존 호출은 NULL이라 서로 충돌하지 않는다. + request_id = Column(String(64), nullable=True) created_at = Column(DateTime, default=datetime.now) def __repr__(self): @@ -590,136 +622,471 @@ def _migrate_positions_partial_tp_done(engine): raise -def _migrate_snapshot_unique_constraint(engine): - """portfolio_snapshots의 구버전 UNIQUE(date) 단독 제약을 (account_key, date)로 재구축. +def _migrate_cash_flow_request_id(engine): + """기존 cash_flows에 웹 입금 멱등성 키와 고유 인덱스를 추가한다.""" + from sqlalchemy import text + + dialect = engine.url.get_dialect().name + with engine.begin() as conn: + if dialect == "sqlite": + table = conn.execute(text( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name='cash_flows'" + )).fetchone() + if not table: + return + columns = conn.execute(text("PRAGMA table_info(cash_flows)")).fetchall() + if not any(row[1] == "request_id" for row in columns): + conn.execute(text( + "ALTER TABLE cash_flows ADD COLUMN request_id VARCHAR(64)" + )) + else: + conn.execute(text( + "ALTER TABLE cash_flows " + "ADD COLUMN IF NOT EXISTS request_id VARCHAR(64)" + )) - account_key 도입 전 스키마의 유산: date 단독 유니크라 두 번째 계정(예: kr_pocket)이 - 같은 날 스냅샷을 저장하는 순간 IntegrityError로 조용히 유실된다(2026-07-06 실측). - SQLite는 인라인 UNIQUE 삭제가 불가하므로 표준 재구축(rename → 신 스키마 생성 → - 복사 → 검증 → 구 테이블 삭제)을 쓴다. 멱등이며, 중간 중단 시 다음 초기화에서 - legacy 테이블을 감지해 복사부터 재개한다(INSERT OR IGNORE + PK로 중복 안전). + conn.execute(text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_cash_flows_idempotency " + "ON cash_flows (mode, account_key, request_id)" + )) + + +def _sqlite_table_columns(conn, table_name): + """SQLite 테이블 컬럼 메타데이터를 name 키로 반환한다.""" + from sqlalchemy import text + + rows = conn.execute(text(f'PRAGMA table_info("{table_name}")')).fetchall() + return {str(row[1]): row for row in rows} + + +def _sqlite_has_unique_columns(conn, table_name, expected_columns): + """SQLite UNIQUE 인덱스의 컬럼 순서를 이름에 의존하지 않고 검증한다.""" + from sqlalchemy import text + + for index_row in conn.execute(text(f'PRAGMA index_list("{table_name}")')).fetchall(): + if not bool(index_row[2]): + continue + index_name = str(index_row[1]).replace('"', '""') + columns = tuple( + str(row[2]) + for row in conn.execute(text(f'PRAGMA index_info("{index_name}")')).fetchall() + ) + if columns == tuple(expected_columns): + return True + return False + + +def _sqlite_mode_column_ready(conn, table_name): + """mode가 NOT NULL이고 DB 기본값이 paper인지 확인한다.""" + row = _sqlite_table_columns(conn, table_name).get("mode") + if row is None or not bool(row[3]): + return False + default = str(row[4] or "").strip().strip("()").strip().strip("'\"").lower() + return default == "paper" + + +def _sqlite_drop_rebuild_indexes(conn, legacy_name): + """rename된 테이블의 명시적 인덱스 이름을 신 테이블에 반납한다. + + SQLite의 ALTER TABLE RENAME은 `ix_positions_account_key` 같은 인덱스 + 이름을 그대로 유지한다. 이를 남겨 두면 create_all이 신 테이블의 + 동명 인덱스를 만들지 못한다. UNIQUE 제약의 auto-index(origin=u)는 + 테이블과 함께 보존해야 하므로 일반 인덱스(origin=c)만 제거한다. """ from sqlalchemy import text - if engine.url.get_dialect().name != "sqlite": - return - cols = ( - "id, date, total_value, cash, invested, daily_return, cumulative_return, " - "mdd, position_count, created_at, account_key, peak_value" + for row in conn.execute(text(f'PRAGMA index_list("{legacy_name}")')).fetchall(): + origin = str(row[3]) if len(row) > 3 else "c" + name = str(row[1]) + if origin == "c" and not name.startswith("sqlite_autoindex_"): + quoted = name.replace('"', '""') + conn.execute(text(f'DROP INDEX IF EXISTS "{quoted}"')) + + +def _sqlite_inferred_mode_expression(conn, ledger, source_alias="legacy"): + """기존 장부 행의 모드를 거래 이력에서 보수적으로 역산한다. + + Position은 account_key+symbol, Snapshot은 account_key의 거래 mode 집합이 + 정확히 하나일 때만 그 mode로 귀속한다. 무이력·혼합 이력은 + paper/live 어느 쪽에도 잘못 영향을 주지 않도록 legacy로 격리한다. + """ + columns = _sqlite_table_columns(conn, "trade_history") + required = {"account_key", "mode"} + if ledger == "position": + required.add("symbol") + if not required.issubset(columns): + return "'legacy'" + + join = f"th.account_key = {source_alias}.account_key" + if ledger == "position": + join += f" AND th.symbol = {source_alias}.symbol" + valid = "NULLIF(TRIM(th.mode), '') IS NOT NULL" + return ( + "CASE WHEN (" + "SELECT COUNT(DISTINCT LOWER(TRIM(th.mode))) FROM trade_history AS th " + f"WHERE {join} AND {valid}" + ") = 1 THEN (" + "SELECT MIN(LOWER(TRIM(th.mode))) FROM trade_history AS th " + f"WHERE {join} AND {valid}" + ") ELSE 'legacy' END" ) - legacy_name = "portfolio_snapshots_legacy_uq" + +def _sqlite_rebuild_mode_ledger( + engine, + *, + table_name, + legacy_name, + target_columns, + missing_defaults, + ledger, + expected_unique, + verify_keys, +): + """rename 후 복사 단계를 멱등·중단 복구 가능하게 수행한다.""" + from sqlalchemy import text + + # 중단이 rename 직후였더라도 인덱스 충돌 제거부터 재개한다. with engine.connect() as conn: - legacy_exists = conn.execute(text( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:n" - ), {"n": legacy_name}).scalar() + _sqlite_drop_rebuild_indexes(conn, legacy_name) + conn.commit() - if not legacy_exists: - ddl = conn.execute(text( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='portfolio_snapshots'" - )).scalar() - if not ddl: - return - normalized = " ".join(str(ddl).split()).lower() - is_legacy = ( - "unique (date)" in normalized - and "uq_snapshots_account_date" not in normalized + Base.metadata.create_all(engine) + + with engine.connect() as conn: + if not _sqlite_has_unique_columns(conn, table_name, expected_unique): + raise RuntimeError( + f"{table_name} 신규 복합 UNIQUE 생성 실패 — legacy 테이블 보존" ) - if not is_legacy: - return - logger.warning( - "portfolio_snapshots 구버전 UNIQUE(date) 감지 — (account_key, date) 복합 제약으로 재구축" + if not _sqlite_mode_column_ready(conn, table_name): + raise RuntimeError( + f"{table_name}.mode NOT NULL/DEFAULT paper 생성 실패 — legacy 테이블 보존" ) - conn.execute(text( - f"ALTER TABLE portfolio_snapshots RENAME TO {legacy_name}" - )) - conn.commit() - # 신 스키마 재생성 (rename으로 본 테이블이 사라졌으므로 create_all이 새로 만든다) - Base.metadata.create_all(engine) + source_columns = _sqlite_table_columns(conn, legacy_name) + inferred_mode = _sqlite_inferred_mode_expression(conn, ledger) + select_expressions = [] + for column in target_columns: + if column == "mode": + if column in source_columns: + select_expressions.append( + "COALESCE(NULLIF(LOWER(TRIM(legacy.mode)), ''), " + f"{inferred_mode})" + ) + else: + select_expressions.append(inferred_mode) + elif column in source_columns: + select_expressions.append(f'legacy."{column}"') + else: + select_expressions.append(missing_defaults.get(column, "NULL")) - with engine.connect() as conn: - before = conn.execute(text(f"SELECT COUNT(*) FROM {legacy_name}")).scalar() + quoted_columns = ", ".join(f'"{column}"' for column in target_columns) + select_sql = ", ".join(select_expressions) + before = int(conn.execute(text(f'SELECT COUNT(*) FROM "{legacy_name}"')).scalar() or 0) conn.execute(text( - f"INSERT OR IGNORE INTO portfolio_snapshots ({cols}) SELECT {cols} FROM {legacy_name}" + f'INSERT OR IGNORE INTO "{table_name}" ({quoted_columns}) ' + f'SELECT {select_sql} FROM "{legacy_name}" AS legacy' )) - after = conn.execute(text("SELECT COUNT(*) FROM portfolio_snapshots")).scalar() - if after < before: + + key_match = " AND ".join( + f'(target."{column}" = legacy."{column}" OR ' + f'(target."{column}" IS NULL AND legacy."{column}" IS NULL))' + for column in verify_keys + ) + copied = int(conn.execute(text( + f'SELECT COUNT(*) FROM "{legacy_name}" AS legacy ' + f'JOIN "{table_name}" AS target ON target.id = legacy.id AND {key_match}' + )).scalar() or 0) + if copied != before: conn.rollback() raise RuntimeError( - f"스냅샷 재구축 검증 실패: 복사 후 {after} < 원본 {before} — legacy 테이블 보존" + f"{table_name} 재구축 검증 실패: {copied}/{before}행 복사 확인 — " + "legacy 테이블 보존" ) - conn.execute(text(f"DROP TABLE {legacy_name}")) + + conn.execute(text(f'DROP TABLE "{legacy_name}"')) conn.commit() - logger.info("portfolio_snapshots 재구축 완료: {}행, 복합 유니크(account_key, date)", after) + logger.info( + "{} 재구축 완료: {}행, mode 격리 UNIQUE{}", + table_name, + before, + expected_unique, + ) -def _migrate_position_unique_constraint(engine): - """positions의 구버전 UNIQUE(symbol) 단독 제약을 (account_key, symbol)로 재구축. +def _postgres_unique_constraints(conn, table_name): + """현재 search_path의 PostgreSQL UNIQUE 제약 {name: normalized definition}.""" + from sqlalchemy import text + + rows = conn.execute(text( + "SELECT c.conname, pg_get_constraintdef(c.oid) " + "FROM pg_constraint AS c " + "WHERE c.conrelid = to_regclass(:table_name) AND c.contype = 'u'" + ), {"table_name": table_name}).fetchall() + return { + str(row[0]): "".join(str(row[1]).lower().replace('"', '').split()) + for row in rows + } + - account_key 도입 전 스키마의 유산(스냅샷 UNIQUE(date)와 같은 계열): symbol 단독 - 유니크라 서로 다른 계좌가 같은 종목을 드는 순간 IntegrityError — 매매 기록은 - 남는데 포지션만 유실돼 평가액이 현금만 남는다(2026-07-07 실측: 트랙 재시작으로 - 아카이브 키에 069500이 남은 상태에서 본 키가 069500 재매수 → 스냅샷 -41%). - 아카이브/본 키 조합만이 아니라 바스켓·전략 트랙이 같은 종목을 겹쳐 들 수 없는 - 구조적 지뢰다. 모델은 이미 복합 제약인데 물리 테이블만 낡았다(create_all은 - 기존 테이블을 못 바꾼다). 표준 재구축(rename → 생성 → 복사 → 검증 → 삭제), - 멱등·중단 재개 가능 — 스냅샷 마이그레이션과 동일 절차. +def _postgres_trade_history_supports(conn, required_columns): + from sqlalchemy import text + + count = conn.execute(text( + "SELECT COUNT(DISTINCT column_name) FROM information_schema.columns " + "WHERE table_schema = ANY(current_schemas(false)) " + "AND table_name = 'trade_history' AND column_name = ANY(:columns)" + ), {"columns": list(required_columns)}).scalar() + return int(count or 0) == len(required_columns) + + +def _postgres_migrate_mode_ledger( + engine, + *, + table_name, + constraint_name, + expected_unique, + obsolete_unique, + inference_sql, + required_trade_columns, +): + """PostgreSQL 장부 모드/제약을 하나의 DDL 트랜잭션으로 전환한다.""" + from sqlalchemy import text + + table_ident = engine.dialect.identifier_preparer.quote(table_name) + constraint_ident = engine.dialect.identifier_preparer.quote(constraint_name) + desired_definition = "unique(" + ",".join(expected_unique) + ")" + obsolete_definitions = { + "unique(" + ",".join(columns) + ")" for columns in obsolete_unique + } + + # PostgreSQL DDL은 transactional이므로 중간 중단 시 전체가 rollback된다. + with engine.begin() as conn: + if conn.execute(text("SELECT to_regclass(:table_name)"), { + "table_name": table_name, + }).scalar() is None: + return + + constraints = _postgres_unique_constraints(conn, table_name) + already_scoped = desired_definition in constraints.values() + + conn.execute(text( + f"ALTER TABLE {table_ident} ADD COLUMN IF NOT EXISTS mode VARCHAR(20)" + )) + conn.execute(text( + f"UPDATE {table_ident} SET mode = LOWER(BTRIM(mode)) WHERE mode IS NOT NULL" + )) + + # 완료 제약이 이미 있으면 과거 legacy 행을 새 거래 이력으로 + # 재분류하지 않는다. 제약 전환 중인 최초 1회에만 역산한다. + if not already_scoped and _postgres_trade_history_supports( + conn, required_trade_columns + ): + conn.execute(text(inference_sql)) + conn.execute(text( + f"UPDATE {table_ident} SET mode = 'legacy' " + "WHERE mode IS NULL OR BTRIM(mode) = ''" + )) + conn.execute(text( + f"ALTER TABLE {table_ident} ALTER COLUMN mode SET DEFAULT 'paper'" + )) + conn.execute(text( + f"ALTER TABLE {table_ident} ALTER COLUMN mode SET NOT NULL" + )) + + constraints = _postgres_unique_constraints(conn, table_name) + for name, definition in constraints.items(): + if definition in obsolete_definitions: + quoted_name = engine.dialect.identifier_preparer.quote(name) + conn.execute(text( + f"ALTER TABLE {table_ident} DROP CONSTRAINT {quoted_name}" + )) + if desired_definition not in constraints.values(): + columns = ", ".join( + engine.dialect.identifier_preparer.quote(column) + for column in expected_unique + ) + conn.execute(text( + f"ALTER TABLE {table_ident} ADD CONSTRAINT {constraint_ident} " + f"UNIQUE ({columns})" + )) + + +def _migrate_snapshot_unique_constraint(engine): + """Snapshot을 (mode, account_key, date)로 격리하고 기존 행을 보수적 이전. + + SQLite는 rename/create/copy/verify/drop을 중단 후 재개 가능하게 수행한다. + PostgreSQL은 transactional DDL로 전체 전환을 원자적으로 수행한다. + 기존 mode가 없으면 account_key의 거래 mode가 하나일 때만 귀속하고, + 혼합·무이력은 legacy로 격리한다. """ from sqlalchemy import text - if engine.url.get_dialect().name != "sqlite": + dialect = engine.url.get_dialect().name + expected_unique = ("mode", "account_key", "date") + if dialect == "postgresql": + _postgres_migrate_mode_ledger( + engine, + table_name="portfolio_snapshots", + constraint_name="uq_snapshots_mode_account_date", + expected_unique=expected_unique, + obsolete_unique=(("date",), ("account_key", "date")), + required_trade_columns=("account_key", "mode"), + inference_sql=""" + WITH inferred AS ( + SELECT s.id, + CASE WHEN COUNT(DISTINCT LOWER(BTRIM(t.mode))) FILTER ( + WHERE NULLIF(BTRIM(t.mode), '') IS NOT NULL + ) = 1 + THEN MIN(LOWER(BTRIM(t.mode))) FILTER ( + WHERE NULLIF(BTRIM(t.mode), '') IS NOT NULL + ) + ELSE 'legacy' + END AS inferred_mode + FROM portfolio_snapshots AS s + LEFT JOIN trade_history AS t ON t.account_key = s.account_key + GROUP BY s.id + ) + UPDATE portfolio_snapshots AS s + SET mode = inferred.inferred_mode + FROM inferred + WHERE s.id = inferred.id + AND (s.mode IS NULL OR BTRIM(s.mode) = '' OR s.mode = 'legacy') + """, + ) + return + if dialect != "sqlite": return - cols = ( - "id, symbol, avg_price, quantity, total_invested, stop_loss_price, " - "take_profit_price, trailing_stop_price, highest_price, strategy, " - "bought_at, updated_at, account_key, partial_tp_done" - ) - legacy_name = "positions_legacy_uq" + table_name = "portfolio_snapshots" + legacy_name = "portfolio_snapshots_legacy_uq" with engine.connect() as conn: - legacy_exists = conn.execute(text( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:n" - ), {"n": legacy_name}).scalar() - + legacy_exists = bool(conn.execute(text( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:name" + ), {"name": legacy_name}).scalar()) if not legacy_exists: - ddl = conn.execute(text( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='positions'" - )).scalar() - if not ddl: + table_exists = bool(conn.execute(text( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:name" + ), {"name": table_name}).scalar()) + if not table_exists: return - normalized = " ".join(str(ddl).split()).lower() - is_legacy = ( - "unique (symbol)" in normalized - and "uq_positions_account_symbol" not in normalized - ) - if not is_legacy: + if ( + _sqlite_has_unique_columns(conn, table_name, expected_unique) + and _sqlite_mode_column_ready(conn, table_name) + ): return logger.warning( - "positions 구버전 UNIQUE(symbol) 감지 — (account_key, symbol) 복합 제약으로 재구축" + "portfolio_snapshots 장부를 UNIQUE(mode, account_key, date)로 재구축" ) conn.execute(text( - f"ALTER TABLE positions RENAME TO {legacy_name}" + f'ALTER TABLE "{table_name}" RENAME TO "{legacy_name}"' )) + _sqlite_drop_rebuild_indexes(conn, legacy_name) conn.commit() - # 신 스키마 재생성 (rename으로 본 테이블이 사라졌으므로 create_all이 새로 만든다) - Base.metadata.create_all(engine) + _sqlite_rebuild_mode_ledger( + engine, + table_name=table_name, + legacy_name=legacy_name, + target_columns=( + "id", "mode", "date", "total_value", "cash", "invested", + "daily_return", "cumulative_return", "mdd", "position_count", + "created_at", "account_key", "peak_value", + ), + missing_defaults={"account_key": "''", "position_count": "0"}, + ledger="snapshot", + expected_unique=expected_unique, + verify_keys=("account_key", "date"), + ) + + +def _migrate_position_unique_constraint(engine): + """Position을 (mode, account_key, symbol)로 격리하고 기존 행을 보수적 이전. + + 기존 mode가 없으면 동일 account_key+symbol의 거래 mode가 하나일 때만 + 귀속하고, 혼합·무이력은 legacy로 격리해 신규 paper/live 조회에서 + 제외한다. SQLite 재구축은 멱등·중단 재개 가능하고 PostgreSQL DDL은 + 단일 트랜잭션으로 원자적으로 수행한다. + """ + from sqlalchemy import text + + dialect = engine.url.get_dialect().name + expected_unique = ("mode", "account_key", "symbol") + if dialect == "postgresql": + _postgres_migrate_mode_ledger( + engine, + table_name="positions", + constraint_name="uq_positions_mode_account_symbol", + expected_unique=expected_unique, + obsolete_unique=(("symbol",), ("account_key", "symbol")), + required_trade_columns=("account_key", "symbol", "mode"), + inference_sql=""" + WITH inferred AS ( + SELECT p.id, + CASE WHEN COUNT(DISTINCT LOWER(BTRIM(t.mode))) FILTER ( + WHERE NULLIF(BTRIM(t.mode), '') IS NOT NULL + ) = 1 + THEN MIN(LOWER(BTRIM(t.mode))) FILTER ( + WHERE NULLIF(BTRIM(t.mode), '') IS NOT NULL + ) + ELSE 'legacy' + END AS inferred_mode + FROM positions AS p + LEFT JOIN trade_history AS t + ON t.account_key = p.account_key AND t.symbol = p.symbol + GROUP BY p.id + ) + UPDATE positions AS p + SET mode = inferred.inferred_mode + FROM inferred + WHERE p.id = inferred.id + AND (p.mode IS NULL OR BTRIM(p.mode) = '' OR p.mode = 'legacy') + """, + ) + return + if dialect != "sqlite": + return + table_name = "positions" + legacy_name = "positions_legacy_uq" with engine.connect() as conn: - before = conn.execute(text(f"SELECT COUNT(*) FROM {legacy_name}")).scalar() - conn.execute(text( - f"INSERT OR IGNORE INTO positions ({cols}) SELECT {cols} FROM {legacy_name}" - )) - after = conn.execute(text("SELECT COUNT(*) FROM positions")).scalar() - if after < before: - conn.rollback() - raise RuntimeError( - f"포지션 재구축 검증 실패: 복사 후 {after} < 원본 {before} — legacy 테이블 보존" - ) - conn.execute(text(f"DROP TABLE {legacy_name}")) - conn.commit() - logger.info("positions 재구축 완료: {}행, 복합 유니크(account_key, symbol)", after) + legacy_exists = bool(conn.execute(text( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:name" + ), {"name": legacy_name}).scalar()) + if not legacy_exists: + table_exists = bool(conn.execute(text( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:name" + ), {"name": table_name}).scalar()) + if not table_exists: + return + if ( + _sqlite_has_unique_columns(conn, table_name, expected_unique) + and _sqlite_mode_column_ready(conn, table_name) + ): + return + logger.warning("positions 장부를 UNIQUE(mode, account_key, symbol)로 재구축") + conn.execute(text( + f'ALTER TABLE "{table_name}" RENAME TO "{legacy_name}"' + )) + _sqlite_drop_rebuild_indexes(conn, legacy_name) + conn.commit() + + _sqlite_rebuild_mode_ledger( + engine, + table_name=table_name, + legacy_name=legacy_name, + target_columns=( + "id", "mode", "symbol", "avg_price", "quantity", "total_invested", + "stop_loss_price", "take_profit_price", "trailing_stop_price", + "highest_price", "strategy", "bought_at", "updated_at", "account_key", + "partial_tp_done", + ), + missing_defaults={"account_key": "''", "partial_tp_done": "0"}, + ledger="position", + expected_unique=expected_unique, + verify_keys=("account_key", "symbol"), + ) def init_database(): @@ -735,8 +1102,8 @@ def init_database(): _migrate_add_account_key(engine) except Exception: pass - # 구버전 UNIQUE(date) 재구축 — 실패 시 조용히 넘기지 않는다(두 번째 계정의 - # 스냅샷이 계속 유실되는 상태를 숨기면 안 됨). 단, legacy가 아니면 no-op. + # 스냅샷 mode 격리 재구축 — 실패를 숨기면 paper/live 장부가 + # 계속 충돌할 수 있으므로 예외를 전파한다. 신 스키마에서는 no-op. _migrate_snapshot_unique_constraint(engine) try: _migrate_trade_history_slippage_columns(engine) @@ -754,9 +1121,10 @@ def init_database(): _migrate_positions_partial_tp_done(engine) except Exception: pass - # 구버전 UNIQUE(symbol) 재구축 — 스냅샷 UNIQUE(date)와 같은 이유로 조용히 넘기지 - # 않는다(계좌 간 동일 종목 보유가 막혀 매수 포지션이 유실되는 상태). legacy가 - # 아니면 no-op. partial_tp_done 컬럼 추가 이후에 실행해야 복사 컬럼이 갖춰진다. + # 입금 재시도 중복은 원금과 TWR을 오염시키므로 마이그레이션 실패를 숨기지 않는다. + _migrate_cash_flow_request_id(engine) + # 포지션 mode 격리 재구축. partial_tp_done 컬럼 추가 이후에 실행해 + # 구버전 테이블을 완전히 복사한다. 신 스키마에서는 no-op. _migrate_position_unique_constraint(engine) if "sqlite" in engine.url.drivername: diff --git a/database/repositories.py b/database/repositories.py index 715b3a1c..3f59e781 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -5,21 +5,34 @@ - 모든 함수에 @with_retry 적용 — WAL 체크포인트 중 일시적 locked에도 안전 """ +import json import re from datetime import datetime, timedelta from typing import List, Optional import pandas as pd -from sqlalchemy import and_ +from sqlalchemy import and_, text +from sqlalchemy.exc import IntegrityError from loguru import logger from database.models import ( get_session, with_retry, StockPrice, TradeHistory, Position, PortfolioSnapshot, DailyReport, FailedOrder, PendingOrderGuard, OrderRecord as DbOrderRecord, CashFlow, + OperationEvent, ) +TRADING_HALT_SET = "TRADING_HALT_SET" +TRADING_HALT_CLEARED = "TRADING_HALT_CLEARED" +TRADING_HALT_STRATEGY = "global_trading_halt" +_TRADING_HALT_ADVISORY_LOCK_KEY = 0x51484C54 # "QHLT" + + +class TradingHaltStateConflict(RuntimeError): + """HALT 해제 기준 이벤트가 이미 최신 상태가 아닐 때 발생한다.""" + + # ============================================================= # 주가 데이터 관련 # ============================================================= @@ -430,6 +443,11 @@ def get_daily_trade_summary( # 포지션 관련 # ============================================================= +def _ledger_mode(mode: str = "paper") -> str: + """장부 mode를 소문자로 정규화한다. 빈 값은 하위 호환상 paper.""" + return str(mode or "paper").strip().lower() or "paper" + + @with_retry def save_position( symbol: str, @@ -440,13 +458,17 @@ def save_position( trailing_stop_price: float = None, strategy: str = "", account_key: str = "", + mode: str = "paper", ) -> Position: - """포지션 저장 (신규 또는 업데이트). account_key: 전략별 계좌 구분.""" + """포지션 저장 (신규 또는 업데이트). mode+account_key 장부별 격리.""" session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) position = session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).first() if position: @@ -468,6 +490,7 @@ def save_position( else: # 신규 포지션 position = Position( + mode=md, account_key=ak, symbol=symbol, avg_price=avg_price, @@ -502,6 +525,7 @@ def replace_position_from_broker( trailing_stop_price: float = None, strategy: str = "", account_key: str = "", + mode: str = "paper", ) -> Position: """ 브로커 잔고 기준으로 포지션을 절대값 보정한다. @@ -512,12 +536,15 @@ def replace_position_from_broker( session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) qty = int(quantity) if qty <= 0: raise ValueError("브로커 보정 수량은 1주 이상이어야 합니다") position = session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).first() if position: position.avg_price = float(avg_price) @@ -528,6 +555,7 @@ def replace_position_from_broker( position.strategy = strategy else: position = Position( + mode=md, account_key=ak, symbol=symbol, avg_price=float(avg_price), @@ -557,24 +585,31 @@ def replace_position_from_broker( @with_retry -def get_position(symbol: str, account_key: str = "") -> Optional[Position]: - """특정 종목의 포지션 조회 (account_key 지정 시 해당 계좌만).""" +def get_position( + symbol: str, account_key: str = "", mode: str = "paper" +) -> Optional[Position]: + """특정 mode+계좌+종목의 포지션 조회.""" session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) return session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).first() finally: session.close() @with_retry -def get_all_positions(account_key: Optional[str] = None) -> List[Position]: - """모든 포지션 조회 (account_key 지정 시 해당 계좌만).""" +def get_all_positions( + account_key: Optional[str] = None, mode: str = "paper" +) -> List[Position]: + """지정 mode의 모든 포지션 조회 (account_key는 선택 필터).""" session = get_session() try: - query = session.query(Position) + query = session.query(Position).filter(Position.mode == _ledger_mode(mode)) if account_key is not None: query = query.filter(Position.account_key == (account_key or "")) return query.all() @@ -583,13 +618,16 @@ def get_all_positions(account_key: Optional[str] = None) -> List[Position]: @with_retry -def delete_position(symbol: str, account_key: str = ""): +def delete_position(symbol: str, account_key: str = "", mode: str = "paper"): """포지션 삭제 (전량 매도 시).""" session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).delete() session.commit() logger.info("포지션 삭제: {}", symbol) @@ -602,18 +640,23 @@ def delete_position(symbol: str, account_key: str = ""): @with_retry -def reduce_position(symbol: str, sell_qty: int, account_key: str = "") -> Optional[Position]: +def reduce_position( + symbol: str, sell_qty: int, account_key: str = "", mode: str = "paper" +) -> Optional[Position]: """ 부분 매도: 수량만 감소, 평균 단가 유지. 남은 수량이 0이면 delete_position 후 None 반환. """ if sell_qty <= 0: - return get_position(symbol, account_key=account_key) + return get_position(symbol, account_key=account_key, mode=mode) session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) position = session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).first() if not position: return None @@ -637,7 +680,13 @@ def reduce_position(symbol: str, sell_qty: int, account_key: str = "") -> Option @with_retry -def update_trailing_stop(symbol: str, current_price: float, trailing_rate: float, account_key: str = ""): +def update_trailing_stop( + symbol: str, + current_price: float, + trailing_rate: float, + account_key: str = "", + mode: str = "paper", +): """ 트레일링 스탑 가격 업데이트 - 현재가가 최고가를 경신하면 스탑가도 갱신 @@ -645,8 +694,11 @@ def update_trailing_stop(symbol: str, current_price: float, trailing_rate: float session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) position = session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).first() if position and current_price > (position.highest_price or 0): position.highest_price = current_price @@ -667,6 +719,7 @@ def update_position_targets( trailing_stop_price: float = None, account_key: str = "", partial_tp_done: bool = None, + mode: str = "paper", ): """ 포지션의 손절/익절/트레일링 가격을 업데이트 (부분 매도 후 재조정 등). @@ -675,8 +728,11 @@ def update_position_targets( session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) position = session.query(Position).filter( - Position.account_key == ak, Position.symbol == symbol + Position.mode == md, + Position.account_key == ak, + Position.symbol == symbol, ).first() if not position: return @@ -696,10 +752,18 @@ def update_position_targets( session.close() -def update_stop_loss_price(symbol: str, stop_loss_price: float, account_key: str = ""): +def update_stop_loss_price( + symbol: str, + stop_loss_price: float, + account_key: str = "", + mode: str = "paper", +): # 래칟 손절 갱신: Position.stop_loss_price 한 필드만 update_position_targets로 위임. update_position_targets( - symbol, stop_loss_price=stop_loss_price, account_key=account_key + symbol, + stop_loss_price=stop_loss_price, + account_key=account_key, + mode=mode, ) @@ -719,8 +783,9 @@ def save_portfolio_snapshot( account_key: str = "", peak_value: float = None, snapshot_date: datetime = None, + mode: str = "paper", ): - """일일 포트폴리오 스냅샷 저장 (account_key: 전략별 계좌 구분). + """일일 포트폴리오 스냅샷 저장 (mode+account_key 장부별 격리). snapshot_date: 스냅샷 귀속 날짜(자정으로 정규화). 미지정 시 오늘. 비거래일 보충 실행에서 NAV의 가격 기준일(직전 거래일)로 귀속할 때 사용. @@ -732,7 +797,9 @@ def save_portfolio_snapshot( base = datetime(base.year, base.month, base.day) today = base.replace(hour=0, minute=0, second=0, microsecond=0) ak = account_key or "" + md = _ledger_mode(mode) snapshot = PortfolioSnapshot( + mode=md, account_key=ak, date=today, total_value=total_value, @@ -744,9 +811,11 @@ def save_portfolio_snapshot( peak_value=peak_value, position_count=position_count, ) - # merge by (account_key, date) + # merge by (mode, account_key, date) existing = session.query(PortfolioSnapshot).filter( - PortfolioSnapshot.account_key == ak, PortfolioSnapshot.date == today + PortfolioSnapshot.mode == md, + PortfolioSnapshot.account_key == ak, + PortfolioSnapshot.date == today, ).first() if existing: existing.total_value = total_value @@ -774,14 +843,18 @@ def save_portfolio_snapshot( @with_retry -def get_latest_peak_value(account_key: str = "") -> float | None: +def get_latest_peak_value( + account_key: str = "", mode: str = "paper" +) -> float | None: """DB에서 가장 최근 스냅샷의 peak_value를 복구. 없으면 None.""" session = get_session() try: ak = account_key or "" + md = _ledger_mode(mode) row = ( session.query(PortfolioSnapshot.peak_value) .filter( + PortfolioSnapshot.mode == md, PortfolioSnapshot.account_key == ak, PortfolioSnapshot.peak_value.isnot(None), ) @@ -807,6 +880,7 @@ def record_cash_flow( occurred_at: Optional[datetime] = None, note: str = "", mode: str = "paper", + request_id: Optional[str] = None, ) -> int: """외부 현금 흐름(+입금/-출금)을 기록하고 id를 반환한다. @@ -818,24 +892,69 @@ def record_cash_flow( if not amount or not math.isfinite(float(amount)): raise ValueError("amount는 0이 아닌 유한한 숫자여야 합니다 (+입금 / -출금)") + ledger_mode = _ledger_mode(mode) + normalized_account = account_key or "" + normalized_note = str(note or "")[:200] + normalized_request_id = str(request_id or "").strip() or None + if normalized_request_id and len(normalized_request_id) > 64: + raise ValueError("request_id는 64자 이하여야 합니다") + session = get_session() try: + def _existing_idempotent_row(): + if not normalized_request_id: + return None + return ( + session.query(CashFlow) + .filter( + CashFlow.mode == ledger_mode, + CashFlow.account_key == normalized_account, + CashFlow.request_id == normalized_request_id, + ) + .first() + ) + + def _validated_existing_id(row): + if row is None: + return None + if ( + float(row.amount) != float(amount) + or str(row.note or "") != normalized_note + ): + raise ValueError( + "같은 request_id가 다른 입금 내용에 사용되었습니다" + ) + return int(row.id) + + existing_id = _validated_existing_id(_existing_idempotent_row()) + if existing_id is not None: + return existing_id + row = CashFlow( - account_key=account_key or "", + account_key=normalized_account, amount=float(amount), occurred_at=occurred_at or datetime.now(), - note=note or "", - mode=mode, + note=normalized_note, + mode=ledger_mode, + request_id=normalized_request_id, ) session.add(row) - session.commit() + try: + session.commit() + except IntegrityError: + session.rollback() + # 동시 재시도가 고유 인덱스에서 경합한 경우 이미 기록된 동일 행을 반환한다. + existing_id = _validated_existing_id(_existing_idempotent_row()) + if existing_id is not None: + return existing_id + raise return int(row.id) finally: session.close() @with_retry -def has_cash_flows(account_key: str = "") -> bool: +def has_cash_flows(account_key: str = "", mode: str = "paper") -> bool: """계정에 외부 현금 흐름 기록이 하나라도 있는가 — TWR 분기 판정용. 순합(net)이 아니라 존재 여부로 판정한다: +100 뒤 -100처럼 순합 0이어도 구간 @@ -845,7 +964,10 @@ def has_cash_flows(account_key: str = "") -> bool: try: return ( session.query(CashFlow.id) - .filter(CashFlow.account_key == (account_key or "")) + .filter( + CashFlow.mode == _ledger_mode(mode), + CashFlow.account_key == (account_key or ""), + ) .first() ) is not None finally: @@ -853,13 +975,16 @@ def has_cash_flows(account_key: str = "") -> bool: @with_retry -def get_cash_flows(account_key: str = "") -> list: +def get_cash_flows(account_key: str = "", mode: str = "paper") -> list: """계정의 외부 현금 흐름 목록 [(occurred_at, amount)...] — 시간가중 자본 계산용.""" session = get_session() try: rows = ( session.query(CashFlow) - .filter(CashFlow.account_key == (account_key or "")) + .filter( + CashFlow.mode == _ledger_mode(mode), + CashFlow.account_key == (account_key or ""), + ) .order_by(CashFlow.occurred_at.asc()) .all() ) @@ -869,13 +994,18 @@ def get_cash_flows(account_key: str = "") -> list: @with_retry -def get_recent_cash_flows(account_key: str = "", limit: int = 12) -> list: +def get_recent_cash_flows( + account_key: str = "", limit: int = 12, mode: str = "paper" +) -> list: """최근 입금/출금 내역 [{occurred_at, amount, note}...] 최신순 — 대시보드 표시용.""" session = get_session() try: rows = ( session.query(CashFlow) - .filter(CashFlow.account_key == (account_key or "")) + .filter( + CashFlow.mode == _ledger_mode(mode), + CashFlow.account_key == (account_key or ""), + ) .order_by(CashFlow.occurred_at.desc()) .limit(int(limit)) .all() @@ -896,11 +1026,15 @@ def get_recent_cash_flows(account_key: str = "", limit: int = 12) -> list: def get_cash_flow_total( account_key: str = "", until: Optional[datetime] = None, + mode: str = "paper", ) -> float: """계정의 외부 현금 흐름 순합(입금-출금). until 지정 시 그 시각 이하만.""" session = get_session() try: - query = session.query(CashFlow).filter(CashFlow.account_key == (account_key or "")) + query = session.query(CashFlow).filter( + CashFlow.mode == _ledger_mode(mode), + CashFlow.account_key == (account_key or ""), + ) if until is not None: query = query.filter(CashFlow.occurred_at <= until) return float(sum(r.amount or 0 for r in query.all())) @@ -913,6 +1047,7 @@ def get_cash_flow_total_between( account_key: str, after: datetime, until: datetime, + mode: str = "paper", ) -> float: """(after, until] 구간의 외부 현금 흐름 순합 — TWR 구간 수익률 보정용. @@ -923,7 +1058,10 @@ def get_cash_flow_total_between( try: rows = ( session.query(CashFlow) - .filter(CashFlow.account_key == (account_key or "")) + .filter( + CashFlow.mode == _ledger_mode(mode), + CashFlow.account_key == (account_key or ""), + ) .filter(CashFlow.occurred_at > after) .filter(CashFlow.occurred_at <= until) .all() @@ -934,7 +1072,9 @@ def get_cash_flow_total_between( @with_retry -def get_latest_snapshot_summary(account_key: str = "") -> Optional[dict]: +def get_latest_snapshot_summary( + account_key: str = "", mode: str = "paper" +) -> Optional[dict]: """가장 최근 스냅샷의 (date, created_at, total_value, cumulative_return) 요약. TWR 체인 계산용 — created_at은 실제 측정 시각이라 '직전 측정 이후 유입' 경계로 @@ -944,7 +1084,10 @@ def get_latest_snapshot_summary(account_key: str = "") -> Optional[dict]: try: row = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == (account_key or "")) + .filter( + PortfolioSnapshot.mode == _ledger_mode(mode), + PortfolioSnapshot.account_key == (account_key or ""), + ) .order_by(PortfolioSnapshot.date.desc()) .first() ) @@ -961,13 +1104,50 @@ def get_latest_snapshot_summary(account_key: str = "") -> Optional[dict]: @with_retry -def get_max_cumulative_return(account_key: str = "") -> Optional[float]: +def get_snapshot_before( + before_date: datetime, account_key: str = "", mode: str = "paper" +) -> Optional[dict]: + """before_date보다 이전 날짜의 가장 최근 스냅샷 요약. + + daily_return(전일 대비) 계산용. get_latest_snapshot_summary를 그대로 쓰면 같은 날 + 재실행(upsert) 때 '오늘 스냅샷'이 직전으로 잡혀 전일 대비가 항상 0이 된다. + """ + session = get_session() + try: + base = before_date.replace(hour=0, minute=0, second=0, microsecond=0) + row = ( + session.query(PortfolioSnapshot) + .filter( + PortfolioSnapshot.mode == _ledger_mode(mode), + PortfolioSnapshot.account_key == (account_key or ""), + PortfolioSnapshot.date < base, + ) + .order_by(PortfolioSnapshot.date.desc()) + .first() + ) + if row is None: + return None + return { + "date": row.date, + "created_at": row.created_at, + "total_value": float(row.total_value), + "cumulative_return": float(row.cumulative_return or 0.0), + } + finally: + session.close() + + +@with_retry +def get_max_cumulative_return( + account_key: str = "", mode: str = "paper" +) -> Optional[float]: """계정 스냅샷의 최대 누적수익률(%). TWR 지수 기준 MDD의 피크 복원용.""" session = get_session() try: row = ( session.query(PortfolioSnapshot.cumulative_return) .filter( + PortfolioSnapshot.mode == _ledger_mode(mode), PortfolioSnapshot.account_key == (account_key or ""), PortfolioSnapshot.cumulative_return.isnot(None), ) @@ -980,12 +1160,19 @@ def get_max_cumulative_return(account_key: str = "") -> Optional[float]: @with_retry -def get_portfolio_snapshots(days: int = 30, account_key: Optional[str] = None) -> pd.DataFrame: - """최근 N일간 포트폴리오 스냅샷 조회 (account_key 지정 시 해당 계좌만).""" +def get_portfolio_snapshots( + days: int = 30, + account_key: Optional[str] = None, + mode: str = "paper", +) -> pd.DataFrame: + """최근 N일간 지정 mode의 포트폴리오 스냅샷 조회.""" session = get_session() try: since = datetime.now() - timedelta(days=days) - query = session.query(PortfolioSnapshot).filter(PortfolioSnapshot.date >= since) + query = session.query(PortfolioSnapshot).filter( + PortfolioSnapshot.mode == _ledger_mode(mode), + PortfolioSnapshot.date >= since, + ) if account_key is not None: query = query.filter(PortfolioSnapshot.account_key == (account_key or "")) results = query.order_by(PortfolioSnapshot.date).all() @@ -1015,6 +1202,7 @@ def get_portfolio_snapshots_between( start_date: datetime, end_date: datetime, account_key: Optional[str] = None, + mode: str = "paper", ) -> List[dict]: """지정 기간 내 포트폴리오 스냅샷 목록 조회 (일별, 시간 무시). account_key 지정 시 해당 계좌만.""" session = get_session() @@ -1022,6 +1210,7 @@ def get_portfolio_snapshots_between( start_naive = start_date.replace(hour=0, minute=0, second=0, microsecond=0) end_naive = end_date.replace(hour=23, minute=59, second=59, microsecond=999999) query = session.query(PortfolioSnapshot).filter( + PortfolioSnapshot.mode == _ledger_mode(mode), PortfolioSnapshot.date >= start_naive, PortfolioSnapshot.date <= end_naive, ) @@ -1056,7 +1245,9 @@ def get_paper_performance_metrics( 없으면 해당 구간 매도 거래의 실현손익 합계로 대체 수익률 추정. account_key 지정 시 해당 계좌만 집계. """ - snapshots = get_portfolio_snapshots_between(start_date, end_date, account_key=account_key) + snapshots = get_portfolio_snapshots_between( + start_date, end_date, account_key=account_key, mode=mode + ) trades = get_trade_history(mode=mode, start_date=start_date, end_date=end_date, account_key=account_key) sell_actions = ("SELL", "STOP_LOSS", "TAKE_PROFIT", "TAKE_PROFIT_PARTIAL", "TRAILING_STOP") sell_trades = [t for t in trades if (t.action or "").upper() in sell_actions] @@ -1184,6 +1375,211 @@ def get_daily_reports(days: int = 30, account_key: Optional[str] = None) -> pd.D session.close() +# ============================================================= +# 전역 거래 HALT 킬스위치 (OperationEvent append-only 상태 로그) +# ============================================================= + +def _required_halt_text(value: str, field: str, max_length: int) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{field}는 빈 값일 수 없습니다") + return text[:max_length] + + +def _trading_halt_state_from_event(event: OperationEvent | None) -> dict: + if event is None: + return { + "halted": False, + "event_id": None, + "event_type": None, + "reason": "", + "source": "", + "mode": None, + "created_at": None, + "detail": {}, + } + + detail = {} + if event.detail: + try: + decoded = json.loads(event.detail) + if isinstance(decoded, dict): + detail = decoded + except (TypeError, ValueError, json.JSONDecodeError): + # 이벤트 종류가 상태의 최종 근거이므로 detail 파싱 실패가 + # 상태 판정을 느슨하게 만들어서는 안 된다. + detail = {} + + return { + "halted": event.event_type == TRADING_HALT_SET, + "event_id": int(event.id), + "event_type": event.event_type, + "reason": str(detail.get("reason") or event.message or ""), + "source": str(detail.get("source") or ""), + "mode": event.mode, + "created_at": event.created_at, + "detail": detail, + } + + +@with_retry +def get_trading_halt_state() -> dict: + """최신 전역 HALT 전환 이벤트를 읽는다. + + 조회 예외는 호출자가 반드시 받도록 전파한다. 주문 경로는 이를 + fail-closed BUY 차단으로 변환하며, SELL은 이 함수를 호출하지 않는다. + """ + session = get_session() + try: + event = ( + session.query(OperationEvent) + .filter(OperationEvent.strategy == TRADING_HALT_STRATEGY) + .filter(OperationEvent.event_type.in_((TRADING_HALT_SET, TRADING_HALT_CLEARED))) + .order_by(OperationEvent.id.desc()) + .first() + ) + return _trading_halt_state_from_event(event) + finally: + session.close() + + +def _record_trading_halt_transition( + event_type: str, + reason: str, + *, + source: str, + mode: str, + detail: Optional[dict] = None, + expected_active_event_id: Optional[int] = None, +) -> dict: + reason_text = _required_halt_text(reason, "reason", 2000) + source_text = _required_halt_text(source, "source", 200) + mode_text = str(mode or "live").strip().lower()[:20] or "live" + payload = dict(detail or {}) + payload.update({ + "reason": reason_text, + "source": source_text, + "global": True, + }) + + session = get_session() + try: + # SET/CLEAR 전환을 같은 직렬화 지점에 묶는다. CLEAR가 상태를 읽은 뒤 + # 새 SET을 덮어쓰는 lost-update를 막으려면 CLEAR만 잠가서는 부족하다. + dialect = session.get_bind().dialect.name + if dialect == "sqlite": + session.execute(text("BEGIN IMMEDIATE")) + elif dialect == "postgresql": + session.execute( + text("SELECT pg_advisory_xact_lock(:lock_key)"), + {"lock_key": _TRADING_HALT_ADVISORY_LOCK_KEY}, + ) + else: # 지원 여부를 증명할 수 없는 DB에서는 안전하게 전환을 거부한다. + raise RuntimeError(f"HALT 전환 락 미지원 DB dialect: {dialect}") + + if event_type == TRADING_HALT_CLEARED: + latest = ( + session.query(OperationEvent) + .filter(OperationEvent.strategy == TRADING_HALT_STRATEGY) + .filter( + OperationEvent.event_type.in_( + (TRADING_HALT_SET, TRADING_HALT_CLEARED) + ) + ) + .order_by(OperationEvent.id.desc()) + .first() + ) + latest_state = _trading_halt_state_from_event(latest) + if not latest_state["halted"]: + raise TradingHaltStateConflict( + "활성 HALT가 없거나 이미 해제되어 CLEAR를 거부합니다" + ) + if int(latest_state["event_id"]) != int(expected_active_event_id): + raise TradingHaltStateConflict( + "HALT 상태가 해제 확인 이후 변경되었습니다: " + f"expected_event_id={expected_active_event_id}, " + f"current_event_id={latest_state['event_id']}" + ) + + event = OperationEvent( + event_type=event_type, + severity="critical" if event_type == TRADING_HALT_SET else "warning", + symbol=None, + strategy=TRADING_HALT_STRATEGY, + message=reason_text, + detail=json.dumps(payload, ensure_ascii=False, default=str), + mode=mode_text, + ) + session.add(event) + session.commit() + session.refresh(event) + state = _trading_halt_state_from_event(event) + logger.log( + "CRITICAL" if state["halted"] else "WARNING", + "전역 거래 HALT 상태 전환: halted={} event_id={} source={} reason={}", + state["halted"], + state["event_id"], + source_text, + reason_text, + ) + return state + except Exception: + session.rollback() + raise + finally: + session.close() + + +@with_retry +def set_trading_halt( + reason: str, + *, + source: str = "operator", + mode: str = "live", + detail: Optional[dict] = None, +) -> dict: + """전 계좌·전 전략 BUY를 막는 영속 HALT를 설정하고 감사 이벤트를 남긴다.""" + return _record_trading_halt_transition( + TRADING_HALT_SET, + reason, + source=source, + mode=mode, + detail=detail, + ) + + +@with_retry +def clear_trading_halt( + reason: str, + *, + source: str = "operator", + mode: str = "live", + confirmed: bool = False, + expected_active_event_id: Optional[int] = None, + detail: Optional[dict] = None, +) -> dict: + """명시적 운영자 확인 후 HALT를 해제하고 append-only 감사 이벤트를 남긴다.""" + if confirmed is not True: + raise ValueError("HALT 해제에는 confirmed=True 운영자 확인이 필요합니다") + try: + expected_id = int(expected_active_event_id) + except (TypeError, ValueError): + raise ValueError("HALT 해제에는 expected_active_event_id가 필요합니다") from None + if expected_id <= 0: + raise ValueError("expected_active_event_id는 양수여야 합니다") + payload = dict(detail or {}) + payload["confirmed"] = True + payload["expected_active_event_id"] = expected_id + return _record_trading_halt_transition( + TRADING_HALT_CLEARED, + reason, + source=source, + mode=mode, + detail=payload, + expected_active_event_id=expected_id, + ) + + # ============================================================= # 주문 실패 Dead-letter 큐 # ============================================================= @@ -1427,6 +1823,39 @@ def has_open_order_record(symbol: str, account_key: str = "", mode: str | None = # 중복 주문 방지 (DB 영속 가드) # ============================================================= +@with_retry +def claim_order_guard(symbol: str, expires_at: datetime) -> bool: + """동일 종목 주문권을 DB UNIQUE 제약으로 원자적으로 획득한다. + + ``has_pending`` 뒤 ``mark_pending`` 하는 check-then-set은 두 프로세스가 + 동시에 통과할 수 있다. 만료 행 삭제와 신규 INSERT를 한 트랜잭션에서 + 수행하고, UNIQUE 충돌은 정상적인 claim 실패로 처리한다. 그 밖의 DB + 오류는 호출자에게 전파해 주문 경로가 fail-closed 하게 한다. + """ + symbol_text = str(symbol or "").strip() + if not symbol_text: + raise ValueError("order guard symbol은 빈 값일 수 없습니다") + + session = get_session() + try: + now = datetime.now() + session.query(PendingOrderGuard).filter( + PendingOrderGuard.symbol == symbol_text, + PendingOrderGuard.expires_at <= now, + ).delete(synchronize_session=False) + session.add(PendingOrderGuard(symbol=symbol_text, expires_at=expires_at)) + session.commit() + return True + except IntegrityError: + session.rollback() + return False + except Exception: + session.rollback() + raise + finally: + session.close() + + @with_retry def save_order_guard(symbol: str, expires_at: datetime): """중복 주문 방지 레코드 저장 (upsert).""" diff --git a/docs/OPERATING_PRINCIPLES.md b/docs/OPERATING_PRINCIPLES.md new file mode 100644 index 00000000..32e35c6c --- /dev/null +++ b/docs/OPERATING_PRINCIPLES.md @@ -0,0 +1,149 @@ +# 운영·개발 원칙 + +이 문서는 취향이 아니라 **사고 기록**이다. 아래 원칙은 전부 이 저장소에서 실제로 터진 +사고에서 나왔고, 각 항목에 그 사고를 붙여 뒀다. 새 기능을 짜거나 파라미터를 만질 때 +이 목록을 먼저 통과시킨다. + +--- + +## 이 시스템의 고유한 실패 방식 + +여기서 발견된 중대 결함은 **한 번도 "크래시"가 아니었다.** 전부 같은 서명을 갖는다: + +> 오류 0건 · 주문 실패 0건 · 스냅샷 정상 · 헬스 ✅ OK +> — 그런데 설계대로 굴러가고 있지 않다. + +| 사고 | 표면 상태 | 실제 상태 | +|---|---|---| +| 자기상관 자기거부 (6/10~8/07) | 정상 | 58거래일 거래 0건, 트랙 동결 | +| 현금 래칫 (8/07~8/26) | 정상 | 배치율 61%→54.9% 누수, 반등 -9.0%p 미스 | +| 유령 손절선 | 포지션에 손절가 기록됨 | 한 번도 평가되지 않음 | +| 유령 낙폭 (7/07) | NAV -41% | 포지션이 조용히 유실된 반쪽 원장 | +| 적립 미실행 (7/10~) | 헬스 OK 47일 | 수익 엔진이 한 번도 안 돌아감 | +| 채울 수 없는 슬롯 | 배치율 미달 | 자본상 편입 불가능한 종목이 목표표에 상주 | + +**따라서 이 시스템의 1순위 품질 목표는 "안 죽는 것"이 아니라 "거짓으로 건강해 보이지 +않는 것"이다.** 아래 원칙은 전부 여기서 파생된다. + +--- + +## 원칙 + +### 1. 선언한 숫자마다 감시가 하나씩 붙는다 +`baskets.yaml`이 주식 60%를 선언하면, 실제가 60%에서 벗어날 때 **울리는 것이 있어야 +한다.** 선언만 하고 감시가 없으면 그 숫자는 문서일 뿐 정책이 아니다. + +> 사고: `target_stock_weight: 0.60`을 명시하면서 `deployment_tolerance: 1.0`(감시 해제)을 +> 그대로 뒀다. 3주간 배치율이 54.9%로 새는 동안 헬스는 계속 OK였다. + +**체크**: 새 설정값을 추가할 때 — 이 값이 지켜지지 않으면 무엇이 울리는가? + +### 2. 침묵은 건강이 아니다 +"오류 0건"은 완전한 기능 정지와 양립한다. 헬스는 **없음(오류)이 아니라 있음(설계대로 +동작)을 주장**해야 한다: 설계대로 배치됐는가, 설계대로 거래하는가, 적립이 들어오는가. + +**체크**: 이 컴포넌트가 아무 일도 안 하고 있으면 어떻게 알아채는가? + +### 3. 전역 안전 파라미터가 명시 정책을 이기지 않는다 +전역 기본값은 *재량 매매*를 위한 안전판이다. 호출부가 목표 비중표처럼 정책을 명시하면, +그 정책이 해당 축의 권위다. 전역값이 조용히 이기면 설계가 실행 불가능해진다. + +> 사고 3건이 전부 이 패턴: 쌍별 상관 거부권(하락장에 대형주 상관→1이라 설계 매수 전부 +> 거부), `max_position_ratio` 20%(47.5% 설계인 kr_pocket을 구조적으로 차단), +> 전역 단타 -3% 손절(buy&hold 포지션에 기록만 되고 평가는 안 됨). + +**단, 무효화가 아니라 위임이다** — 집중도·노출 상한은 *그 정책의 언어로* 계속 강제한다 +(예: 상관 거부권 대신 "그 종목 자기 목표 비중 + 드리프트 허용" 상한). + +**체크**: 이 게이트가 명시 정책을 만나면 어떻게 되는가? 무효화인가 위임인가? + +### 4. 한쪽으로만 도는 메커니즘을 의심한다 +한 방향으로만 발동할 수 있는 규칙은 반드시 단조 표류한다. **양방향을 다 확인한다.** + +> 사고: 매도는 min_trade를 넘겨 집행되고(현금↑), 매수는 종목별로 쪼개져 min_trade +> 미만이라 집행 불가(현금↓ 불가) → 현금 래칫. + +**체크**: 이 규칙의 역방향은 같은 조건에서 발동 가능한가? + +### 5. 경보는 새로운 사실일 때만 운다 +복구 불가능한 과거 사건을 매일 다시 알리면 신호가 죽는다. 이 저장소에서 경보 위생 +문제는 **5번째** 재발이다(게이트 NO-GO → 바스켓별 허용오차 → 구조적 허용오차 → +런타임 유휴 상태 → 결측 재경보). + +> 사고: 8/18 결측 하나가 3주간 16건의 warning을 만들어 다른 신호를 덮었다. + +**체크**: 이 경보는 상황이 지속되면 몇 번 울리는가? 1회인가 매일인가? + +### 6. 소급 적용은 규칙이 아니라 결정이다 +새 리스크 정책은 **앞으로만** 구속력을 갖는다. 이미 임계값을 넘어 있는 포지션에 +자동 적용하면, 그건 손실을 제한하는 게 아니라 최악의 순간에 파는 것이다. + +> 사고: -25% 손절을 도입한 사이클이 이미 -34.8%였던 현대차를 -34.36%에 청산했다. +> "손실을 25%로 제한"한 게 아니라 그냥 34% 손실을 확정한 것이다. + +**체크**: 이 정책을 켤 때 기존 상태는 어떻게 되는가? 유예인가 즉시 집행인가? + +### 7. 운영 DB 사본에서 실제 경로를 태운다 +목 기반 테스트 1900개가 전부 통과하는 동안 바스켓은 58일 동결돼 있었다. 행위를 바꾸는 +수정은 **`QUANT_DB_PATH`로 운영 DB 사본을 잡고 진짜 실행 경로**를 통과시켜 확인한다. + +```bash +# 표준 검증 절차 (운영 DB·알림 무접촉) +QUANT_DB_PATH=<사본> QUANT_BACKUP_PATH=<임시> \ + .venv/Scripts/python.exe <검증스크립트> # plan_* → execute까지 실제 호출 +``` + +**체크**: 이 수정이 진짜로 동작하는 걸 운영 데이터로 봤는가, 목으로만 봤는가? + +### 8. 자본 제약은 전략의 일부다 +1주 가격이 슬롯 목표금액을 넘으면 그 종목은 **이 자본 규모에서 존재하지 않는다.** +목표 비중표에 남겨 두면 영구 공백이 되고, 그 사실은 "배치율 미달"이라는 엉뚱한 +증상으로만 나타나 원인이 가려진다. + +> 사고: 000660 슬롯(1주 148만 > 슬롯 76만)이 2개월간 10% 공백으로 상주. + +**체크**: 이 비중표는 현재 자본에서 전부 채울 수 있는가? (`test_shipped_basket_slots_are_fillable`) + +### 9. 성과는 양방향 국면으로 나눠 잰다 +방어적 포지션은 하락장에서 **항상** 좋아 보인다. 전체 구간 수익률 하나로 보고하면 +상승장 미스가 통째로 숨는다. + +> 사고: 전체 구간 +7.8%p 초과성과가 반등 구간 -9.0%p 미스를 덮고 있었다. +> 두 숫자는 같은 포트폴리오의 같은 3개월이다. + +**체크**: 상승 구간과 하락 구간을 따로 보고했는가? + +### 10. 커밋은 무엇을 고쳤는지가 아니라 무엇을 알게 됐는지를 남긴다 +이 저장소의 결함은 재발성이 높고(경보 위생 5회, 전역 파라미터 3회), 원인은 항상 +코드보다 **가정**에 있었다. 커밋 메시지에 실측 수치와 그 수치를 만든 메커니즘을 적는다. + +--- + +## 앞으로의 개발 우선순위 + +각 항목은 위 원칙 중 하나를 코드로 만드는 일이다. + +| 순위 | 항목 | 원칙 | 상태 | +|---|---|---|---| +| P0 | 집계 배치율 트리거 + 보충 매수 | 1, 4 | ✅ 2026-08-26 | +| P0 | 배치율 감시 복원 | 1, 2 | ✅ 2026-08-26 | +| P0 | 결측 경보 중복 억제 | 5 | ✅ 2026-08-26 | +| P0 | 적립 이행 점검 | 2 | ✅ 2026-08-26 | +| P1 | **국면 분해 리포팅** — 주간/평가 리포트에 상승·하락 구간 성과를 분리 표기 | 9 | 미착수 | +| P1 | **리스크 지표 표면화** — `daily_return` 복구(8/10~)로 이제 계산 가능해진 변동성·샤프·하락일 비율을 일일/주간 리포트에 | 2 | 미착수 | +| P2 | **정책 도입 유예 장치** — `risk:` 블록에 `effective_from`을 두고, 그 이전 진입 포지션은 자동 청산 대신 운영자 결정 목록으로 | 6 | 미착수 | +| P2 | **1회 손실 예산 정합** — `_execute_buy_quantity_impl`의 per-trade 1% 예산이 아직 전역 -3% 손절로 계산된다. 트랙 정책(-25%)을 넣으면 10% 슬롯이 예산을 초과해 재동결되므로, 목표비중 주문에는 별도 기준이 필요 | 3 | 미착수 | +| P3 | **노출 정책 결정 지원** — 60% vs 80%의 상승/하락 비대칭을 실측 데이터로 시뮬레이션하는 도구 | 9 | 미착수 | + +--- + +## 절대 하지 않는 것 + +- **운영 DB에 직접 쓰기.** 검증은 항상 사본에서. 데이터 수정이 필요하면 도구를 만들고 + 운영자가 실행한다. +- **파라미터를 "정상값"으로 되돌리기.** 소액 특화값(`min_trade_amount: 50000`, + `max_turnover_ratio: 0.6`)을 일반 기본값으로 바꾸면 바스켓이 조용히 죽는다. + 테스트로 고정돼 있다 — 그 테스트가 막으면 이유를 읽어라. +- **경보를 끄는 것으로 경보 피로 해결하기.** 끄지 말고 *울리는 조건*을 정확하게 만든다. + (허용오차 1.0으로 감시를 꺼 뒀다가 3주간 누수를 놓친 것이 바로 이 실수다.) +- **수익률 한 숫자로 성과 보고하기.** 원칙 9. diff --git a/docs/POCKET_TRACK_PLAN.md b/docs/POCKET_TRACK_PLAN.md index af63cd20..9915d24a 100644 --- a/docs/POCKET_TRACK_PLAN.md +++ b/docs/POCKET_TRACK_PLAN.md @@ -46,16 +46,19 @@ v1(지수 50 / 현금 50)의 현금 절반은 이자 0으로 잠들어 있었다 - **구성: KODEX 200 (069500) 47.5% + TIGER CD금리투자KIS(합성) (357870) 47.5% + 현금 5%** (`target_stock_weight: 0.95`, holdings 0.5/0.5, 바스켓별 `min_cash_ratio: 0.05`). -- 357870은 CD 91일물 금리를 일할 누적하는 무분배(TR형) 파킹 ETF — 가격이 사실상 - 단조 증가(일 변동 ±수원), 시장 위험 없이 연 ~3%대. **위험자산 노출은 종전 절반 - 원칙 그대로**(50%→47.5%)이고, 무수익이던 절반만 이자 자산이 됐다. 총자산 기준 - 기대 상승분 연 +1%대 — 30만 원에선 커피값이지만 적립이 쌓일수록 같이 커진다. -- 비용 정합: 두 ETF 모두 **매도 거래세 면제**를 체결 모델에도 반영 - (`transaction_costs.tax_exempt_symbols`) — 종전엔 개별주 세율 0.20%가 일괄 - 적용돼 비용이 과대계상됐다(승격 게이트의 비용 상한 판정 왜곡 요인). -- 한계 고지(정직): KODEX 200 분배금(연 ~1.5-2%)은 페이퍼가 수령하지 못한다 — - 실계좌 대비 **보수적** 편향이라 승격 판단을 부풀리지 않는다. 357870은 무분배라 - 누락 없음. 합성 ETF 스왑 상대방 위험은 CD금리 상품에선 미미. +- 357870은 CD 91일물 금리 지수를 추종하는 무분배(TR형) ETF다. **위험자산 노출은 + 종전 절반 원칙 그대로**(50%→47.5%)지만, 예금이 아니므로 원금이 보장되지 않고 + CD금리 변동·합성 거래상대방·유동성 위험이 남는다. 기대수익은 고정 3%로 두지 않고 + 관측 기간의 실제 세후 수익률로 판정한다. +- 비용 정합: 두 ETF의 **증권거래세 면제**를 반영하되, 국내주식형이 아닌 357870은 + 매매차익과 과표기준가 상승분 중 작은 금액에 15.4% 보유기간 과세가 적용된다. + 과표기준가 시계열이 없는 현재 모델은 양의 매매차익 전액 × 15.4%를 차감하는 + 보수적 상한을 사용한다(`holding_period_income_tax`). + 공식 근거: [한국거래소 ETF 세금제도](https://regulation.krx.co.kr/contents/RGL/03/03060105/RGL03060105.jsp), + [357870 투자설명서](https://www.tigeretf.com/upload/etf/20250627095710000264.pdf). +- 한계 고지(정직): KODEX 200 분배금은 페이퍼가 수령하지 못해 실계좌 대비 수익이 + 보수적으로 잡힌다. 357870은 무분배지만 보유기간 과세와 합성 거래상대방 위험을 + 별도로 반영해야 하며, 실제 세금은 증권사 체결·세금 명세를 최종 기준으로 삼는다. - E2E 검증(격리 DB, 실가격): 사이클1 지수 1주 매수(회전상한 60% 내 큰 슬롯 우선) → 사이클2 파킹 2주 매수 → 사이클3 드리프트 9.4%p 감지되나 '부족분 < 1주'로 0주문(조용) — 정상 정착 상태. 30만 시점 배치율 ~80%(정수 절사), 적립 시 수렴. diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index c5c241a1..66374fc9 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -41,7 +41,7 @@ 5. **실전 (live)** `ENABLE_LIVE_TRADING=true` + `--confirm-live` + 전략 상태 레지스트리의 live 허용 + canonical live gate 통과 필수. KIS API 인증 → `PortfolioManager.sync_with_broker()` → `Scheduler` 무한 루프. - **장전(08:50)** 데이터 수집·전략 분석·**시장 국면 필터** 확인 후 매수 후보 선정(`auto_entry: true` 시 장중 매수). - - **장중(09:00~15:30)** 10분 간격으로 최대 보유 기간 초과 정리·신호·손절/익절 확인 → **시장 국면 필터** 통과 시에만 진입 후보 실행 → `OrderExecutor`가 KIS API로 실제 주문. live에서는 장중 모니터링 루프에서 설정 주기(`sync_broker_interval_minutes`)에 따라 KIS↔DB 잔고를 먼저 크로스체크하며, 실패하거나 불일치하면 신규 진입 후보 실행·재스캔은 보류하고 exit 계열 점검은 유지한다. 주문 전 OrderGuard·KIS 미체결 조회로 중복 방지하며, 조회 실패는 “미체결 없음”이 아니라 주문 보류로 처리한다. live 주문 ACK 후 체결가·수량이 확인되지 않거나 부분체결이면 `order_pending=True`, `requires_reconcile=True`로 장부 반영을 보류하고 같은 장중 루프의 남은 신규 진입도 중단한다. + - **장중(09:00~15:30)** 10분 간격으로 최대 보유 기간 초과 정리·신호·손절/익절 확인 → **시장 국면 필터** 통과 시에만 진입 후보 실행 → `OrderExecutor`가 KIS API로 실제 주문. live에서는 장중 모니터링 루프에서 설정 주기(`sync_broker_interval_minutes`)에 따라 KIS↔DB 잔고를 먼저 크로스체크하며, 실패하거나 불일치하면 신규 진입 후보 실행·재스캔은 보류하고 exit 계열 점검은 유지한다. 주문 전 OrderGuard·KIS 미체결 조회로 중복 방지하며, 조회 실패는 “미체결 없음”이 아니라 주문 보류로 처리한다. 긴급 SELL은 같은 종목의 취소 가능한 BUY 잔량을 먼저 취소하고 재조회에서 사라진 경우에만 제출하며, 기존 BUY의 부분 체결이나 취소 불명은 HALT·reconcile로 전환한다. live 주문 ACK 후 체결가·수량이 확인되지 않거나 부분체결이면 `order_pending=True`, `requires_reconcile=True`로 장부 반영을 보류하고 같은 장중 루프의 남은 신규 진입도 중단한다. - **장마감(15:35)** 일일 리포트·스냅샷·KIS 크로스체크·DB 백업(설정 시)·디스코드. 6. **공통** @@ -275,7 +275,7 @@ quant_trader/ | **indicator_engine.py** | pandas-ta로 RSI, MACD, 볼린저, MA, 스토캐스틱, ADX, ATR, OBV, volume_ratio. `calculate_all(df)`로 지표 컬럼 추가. | | **signal_generator.py** | `strategies.yaml` 스코어링 가중치로 점수 합산 → BUY/SELL/HOLD. `generate(df)`, `get_latest_signal(df)`. **`collinearity_mode`**: `max_per_direction`(방향별 최대 1개) 또는 `representative_only`(3그룹 대표 1개씩=MACD+볼린저+거래량만 사용, 권장). 초기화 시 가격 모멘텀 그룹 다중공선성 경고 자동 출력. | | **risk_manager.py** | 포지션 사이징(1% 룰), `check_diversification`(**업종 비중 포함**: `max_sector_ratio`, FDR/KRX KIND 업종 매핑), `check_recent_performance`, 손절/익절/트레일링, MDD·일일 손실 한도 계산. `calculate_transaction_costs`. | -| **order_executor.py** | `trading.mode`: paper면 DB만, live면 KIS API. 거래 시간·블랙스완 쿨다운·**실적 발표일 필터**(`skip_earnings_days`) 검사, 재시도(지수 백오프+지터). PositionLock, OrderGuard·KIS 미체결 조회. `max_monthly_roundtrips`로 종목·모드·계좌별 월간 신규 BUY 횟수를 운영 주문에서도 제한하고, `drawdown.max_portfolio_mdd`/`max_daily_loss`에 닿으면 신규 BUY만 fail-closed 차단한다. MDD 한도 돌파 후에는 **히스테리시스**(RiskManager.check_mdd의 `_is_halted`)가 한도의 절반 아래로 회복할 때까지 신규 BUY 차단을 유지한다 — 한도 경계 깜빡 회복 시 재진입→재돌파 반복 방지(상시 스케줄러에서 누적 동작, 1회성 CLI는 정적 체크와 동일). live 손실 한도 확인에서 KIS 잔고가 확인되지 않으면 DB fallback 평가금액으로 신규 BUY를 판단하지 않고 차단한다. 시장 국면 필터가 `allow_buys=false`를 반환하면 직접 executor BUY 호출도 주문 전 중단한다. SELL/exit는 손실 축소 경로라 월간 cap·손실 한도와 무관하게 계속 허용한다. live 주문 전 미체결 조회 실패는 `live_unfilled_check.checked=False`로 fail-closed 차단한다. live 주문 ACK 뒤 체결가·체결수량 확인이 안 되거나 부분체결이면 `ACKED`/`PARTIAL_FILLED` pending으로 `order_records`에 저장하고 DB 거래·포지션 반영을 보류한다. `SUBMITTED`/`ACKED`/`PARTIAL_FILLED` 상태가 남아 있으면 OrderGuard TTL이 지나도 같은 종목 신규 주문을 차단한다. 이때 반환값의 `success=False`는 브로커 주문 없음이 아니라 reconcile 필요 상태다. **Dead-letter 큐**: 모든 재시도 실패 시 `FailedOrder` 테이블에 영구 저장. | +| **order_executor.py** | `trading.mode`: paper면 DB만, live면 KIS API. 거래 시간·블랙스완 쿨다운·**실적 발표일 필터**(`skip_earnings_days`) 검사, 재시도(지수 백오프+지터). PositionLock, OrderGuard·KIS 미체결 조회. `max_monthly_roundtrips`로 종목·모드·계좌별 월간 신규 BUY 횟수를 운영 주문에서도 제한하고, `drawdown.max_portfolio_mdd`/`max_daily_loss`에 닿으면 신규 BUY만 fail-closed 차단한다. MDD 한도 돌파 후에는 **히스테리시스**(RiskManager.check_mdd의 `_is_halted`)가 한도의 절반 아래로 회복할 때까지 신규 BUY 차단을 유지한다 — 한도 경계 깜빡 회복 시 재진입→재돌파 반복 방지(상시 스케줄러에서 누적 동작, 1회성 CLI는 정적 체크와 동일). live 손실 한도 확인에서 KIS 잔고가 확인되지 않으면 DB fallback 평가금액으로 신규 BUY를 판단하지 않고 차단한다. 시장 국면 필터가 `allow_buys=false`를 반환하면 직접 executor BUY 호출도 주문 전 중단한다. SELL/exit는 손실 축소 경로라 월간 cap·손실 한도와 무관하게 계속 허용한다. 긴급 SELL 전에는 KIS 정정취소 가능 BUY를 조회·취소하고 재조회로 잔량 소멸을 확인한다. 부분 체결·취소 불명·로컬 주문상태 정리 실패는 매도 수량을 추측하지 않고 HALT한다. live 주문 전 미체결 조회 실패는 `live_unfilled_check.checked=False`로 fail-closed 차단한다. live 주문 ACK 뒤 체결가·체결수량 확인이 안 되거나 부분체결이면 `ACKED`/`PARTIAL_FILLED` pending으로 `order_records`에 저장하고 DB 거래·포지션 반영을 보류한다. `SUBMITTED`/`ACKED`/`PARTIAL_FILLED` 상태가 남아 있으면 OrderGuard TTL이 지나도 같은 종목 신규 주문을 차단한다. 이때 반환값의 `success=False`는 브로커 주문 없음이 아니라 reconcile 필요 상태다. **Dead-letter 큐**: 모든 재시도 실패 시 `FailedOrder` 테이블에 영구 저장. | | **portfolio_manager.py** | 보유 포지션·잔고·수익률. `sync_with_broker()`로 KIS 잔고↔DB 크로스체크. live `get_portfolio_summary()`는 조회용 DB fallback 여부를 `broker_balance_ok/source/error`로 표시하고, 주문 sizing에 쓰는 `get_current_capital()`/`get_available_cash()`는 KIS 잔고 미확인 시 예외로 fail-closed 처리한다. 자동보정 시 KIS 기준 수량을 절대값으로 반영하고, 복구 포지션에는 손절·익절·트레일링 스탑을 재생성한다. | | **basket_rebalancer.py** | 바스켓 리밸런싱 엔진. `baskets.yaml`에서 바스켓 로드. `get_target_weights()`(신호 가중 지원), `get_current_weights()`, `calculate_drift()`, `should_rebalance()`(drift/weekly/monthly 트리거), `plan_rebalance()`(SELL→BUY 순서, max_turnover 제한), `execute()`(dry_run 지원). paper BUY 실행은 `PortfolioManager.get_current_capital()`/`get_available_cash()`로 자본과 현금을 조회한 뒤 `OrderExecutor.execute_buy_quantity()`에 넘긴다. live 실행은 `basket_rebalance:` 승인 단위가 account_key와 주문 strategy에 동일하게 전달되고, KIS↔DB 포지션 동기화가 성공해야 주문 실행부에 도달한다. CLI와 스케줄러 자동 체크 모두 같은 승인 단위를 사용한다. `get_status_report()`로 현황 리포트 생성. | | **scheduler.py** | 실전 무한 루프. 장전/장중/장마감. **시장 국면 필터**(단계적: bearish→매수 중단, caution→사이징 축소). 장중 10분 간격. 루프 10분 초과 시 다음 사이클 스킵. 장전 단계에서 **바스켓 리밸런싱 자동 체크** (`_run_basket_rebalance_check`). live에서는 바스켓별 `basket_rebalance:` readiness gate와 KIS↔DB 동기화를 통과한 뒤에만 주문 계획과 실행으로 넘어간다. live 신규 진입 중 `requires_reconcile` 또는 `order_pending` 결과가 나오면 남은 BUY 후보와 같은 루프 재스캔을 중단하고, 다음 성공적인 KIS↔DB 동기화 전까지 미확정 체결분을 무시한 추가 진입을 막는다. **전략 레지스트리** 기반 `_get_strategy()`. | diff --git a/docs/SAFETY_MODEL.md b/docs/SAFETY_MODEL.md new file mode 100644 index 00000000..64181b10 --- /dev/null +++ b/docs/SAFETY_MODEL.md @@ -0,0 +1,145 @@ +# 안전 모델과 실전 운영 경계 + +## 결론부터 + +이 프로그램은 **원금 보전, 무손실, 수익 또는 주문 체결을 보장하지 않는다.** 안전장치는 +실수·중복 주문·장부 오염을 줄이고 손실 한도를 지키기 위한 방어선이지, 시장 위험을 없애는 +수단이 아니다. 갭, 급격한 변동, 거래정지, VI, 유동성 고갈, 슬리피지, 증권사·네트워크 장애, +전략 성능 저하 때문에 설정한 손절가보다 큰 손실이나 주문 미체결이 발생할 수 있다. + +**현재 실전 판정은 NO-GO다.** 운영자는 최신 코드와 설정으로 만든 검증 증거, paper 운영 +게이트, KIS 연결·잔고 동기화, 명시적 계좌 라우팅, clean Git worktree를 모두 통과하기 전에는 +실전 주문을 열면 안 된다. 게이트 통과는 미래 수익을 뜻하지 않으며, 통과 후에도 소액으로 +별도 승인해야 한다. + +## 기본 원칙 + +- 조회 실패, NaN/무한대, 상태 불명, 검증 증거 불일치는 성공으로 간주하지 않고 주문을 막는다. +- 신규 BUY는 노출·현금·종목 수·1회 손실 예산 등 위험 한도를 주문 후 예상 상태로 검사한다. +- 손절과 긴급 청산 SELL은 신규 BUY 차단과 분리해 열어 두되, 실제 체결은 보장하지 않는다. +- 긴급 SELL 직전 같은 종목의 취소 가능한 BUY를 KIS에서 조회하고, 가능 수량을 취소한 뒤 + 재조회에서 사라진 것이 확인돼야 SELL을 제출한다. 부분 체결이나 조회·취소 불명은 HALT한다. +- 실계좌 KIS 주문 함수의 직접 호출은 차단하고, 주문 실행기의 가드가 승인한 짧은 구간에서만 + 제출한다. +- `use_mock=true`라도 실효 URL이 공식 VTS(`openapivts`)가 아니면 실돈 가능 endpoint로 취급해 + kill switch, 실계좌 리스크 프로필과 주문 capability를 모두 요구한다. +- 실전 주문 응답을 잃었을 때는 같은 주문을 재전송하지 않는다. 중복 체결보다 운영 중단과 + 수동 대조를 우선한다. + +## paper/live 장부 격리 + +`Position`과 `PortfolioSnapshot`은 `mode`를 포함해 각각 +`(mode, account_key, symbol)`, `(mode, account_key, date)` 단위로 유일하다. 관련 저장소 조회와 +쓰기에도 `paper` 또는 `live` 모드를 전달하므로 같은 전략·종목이라도 두 장부가 서로 덮어쓰지 +않는다. + +기존 DB의 mode 없는 행은 거래 이력의 mode가 하나로 일관될 때만 그 mode로 귀속한다. 거래 +이력이 없거나 paper/live가 섞인 행은 `legacy`로 격리하며 신규 paper/live 조회에 포함하지 +않는다. 이 이전은 브로커 잔고가 맞다는 증명이 아니다. 마이그레이션 전 백업을 보존하고, +legacy 행은 KIS 체결·잔고와 수동 대조한 뒤 별도 처리한다. + +## 실전 진입 조건 + +실전은 설정 파일의 기본 계좌로 조용히 폴백하지 않는다. 실행할 전략 키를 +`config/settings.yaml`의 `kis_api.accounts`에 먼저 선언하고 계좌번호는 `.env`로 주입한다. + +```yaml +kis_api: + accounts: + scoring: "" # 실제 번호는 저장소에 커밋하지 않는다 +``` + +```dotenv +KIS_ACCOUNT_NO_SCORING=12345678-01 +``` + +키에 `:` 또는 `-`가 있으면 환경변수 이름에서는 `_`로 바뀐다. 예를 들어 +`basket_rebalance:kr_pocket`은 `KIS_ACCOUNT_NO_BASKET_REBALANCE_KR_POCKET`이다. YAML에 키를 +선언하지 않은 `KIS_ACCOUNT_NO_*` 환경변수는 무시되며 경고가 남는다. 같은 계좌를 여러 전략이 +공유하려는 경우에도 각 전략 키에 의도적으로 같은 번호를 선언해야 한다. + +실전 명령은 다음 두 운영자 확인을 모두 요구하지만, 이것만으로 충분하지 않다. + +```powershell +$env:ENABLE_LIVE_TRADING = "true" +.venv\Scripts\python.exe main.py --mode live --strategy scoring --confirm-live +``` + +이후에도 다음 조건 중 하나라도 실패하면 진입하지 않는다. + +- 전략 레지스트리의 live 허용 상태와 최신 canonical 승격 증거 +- 증거의 commit/config hash, 최신성, paper 품질 및 blocker 판정 +- tracked/untracked 변경이 모두 없는 clean Git worktree; Git 상태 확인 실패도 차단 +- 단일 live 런타임 락 획득 +- KIS 인증·연결·잔고 조회 및 KIS↔DB 포지션 동기화 +- 전략별 `kis_api.accounts` 라우팅 + +`--mode schedule`은 paper 전용이다. 게이트를 우회하는 `--force-live` 경로는 없다. + +## 실전 긴급 전량 청산 + +실계좌를 대상으로 할 때는 `--liquidate-live`를 반드시 명시한다. 이 플래그가 없으면 CLI 청산은 +paper 장부를 대상으로 한다. + +```powershell +$env:ENABLE_LIVE_TRADING = "true" +.venv\Scripts\python.exe main.py --mode liquidate --liquidate-live --confirm-live +``` + +실전 청산은 브로커 동기화나 포지션 조회보다 먼저 영속 전역 HALT를 기록해 다른 프로세스의 +신규 BUY를 막는다. HALT는 청산 종료 후 자동 해제되지 않는다. KIS 체결·미체결·잔고와 DB를 +대조한 뒤에만 사유를 남겨 해제한다. + +```powershell +.venv\Scripts\python.exe tools/clear_trading_halt.py --confirm --reason "KIS 체결·미체결·잔고 대조 완료" +``` + +손절, 트레일링 스탑, 갭다운, 블랙스완, 강제 청산 등 긴급 사유의 국내 주식 SELL은 KIS 시장가 +(`ORD_DVSN=01`, 가격 0)로 제출한다. 이는 오래 남는 지정가보다 체결 가능성을 우선하는 선택일 +뿐이다. 유동성이 부족하거나 거래가 정지되면 미체결·부분 체결될 수 있고, 급변장에서는 예상보다 +매우 불리한 가격에 체결될 수 있다. 시장가 주문도 손실 상한을 보장하지 않는다. + +긴급 SELL 전에 KIS `주식정정취소가능주문조회`로 같은 종목의 BUY 가능수량을 확인하고, +`주식주문(정정취소)`로 그 수량을 취소한다. 취소 접수 응답만 믿지 않고 다시 조회해 BUY가 +사라졌을 때만 시장가 SELL로 넘어간다. 취소 응답 유실도 재전송하지 않고 재조회로 판정한다. +기존 BUY가 일부라도 체결된 경우에는 DB 보유수량으로 매도 수량을 추측하지 않는다. 잔량 취소 후 +전역 HALT와 장기 주문 가드를 유지하고 KIS 잔고·체결·DB를 대조해야 한다. + +## 체결 불확실성과 장부 실패 대응 + +다음 상태는 정상 완료가 아니라 **운영 사고 대응 상태**다. + +- 주문 응답 유실로 접수 여부를 모름 +- 부분 체결 또는 체결 수량·가격을 확정하지 못함 +- 브로커 체결 후 `TradeHistory`/`Position` 저장이 완료되지 않음 + +이 경우 주문을 재전송하지 않고, 미완료 주문 기록과 종목 중복 주문 가드를 유지·연장한다. +전역 HALT와 critical 알림을 남기고 장부 반영을 보류하거나 가능한 DB 기록을 되돌린다. 자동 +처리가 끝났다는 뜻이 아니며 다음 순서로 수동 복구해야 한다. + +1. live 프로세스와 신규 주문을 중지하고 HALT를 유지한다. +2. KIS 주문·체결 내역, 미체결 수량, 현재 잔고를 주문번호 기준으로 확인한다. +3. 동일한 `mode=live`와 `account_key`의 `OrderRecord`, `TradeHistory`, `Position`을 대조한다. +4. 부분 체결 수량과 실제 평균 체결가를 확정해 승인된 reconcile 절차로 장부를 복구한다. +5. 다시 잔고 동기화를 통과하고 미체결이 없음을 확인한 뒤에만 HALT를 명시적으로 해제한다. + +DB를 추측으로 직접 수정하거나, 응답이 없었다는 이유로 같은 주문을 다시 보내면 안 된다. + +## 알려진 한계 + +- 긴급 국내주식 SELL과 충돌하는 BUY의 **취소**만 구현했다. 임의 주문 정정(replace), 해외주식 + 자동 취소, 증권사 서버에 상주하는 native stop 주문은 구현하지 않았다. 손절은 프로그램이 실행 + 중이고 시세·API가 정상일 때 감지해 주문하므로 프로세스 중단이나 통신 장애 사이의 손실을 막지 + 못한다. +- 취소 직전·직후에 체결이 경합할 수 있다. 부분 체결이나 취소 확인 실패는 자동으로 매도 수량을 + 보정하지 않고 HALT하므로 KIS 체결·잔고와 DB를 수동 대조해야 한다. +- 호출 제한기는 같은 Python 프로세스 안에서 동일한 KIS app key·도메인을 쓰는 인스턴스끼리 + 초당/분당 예산을 공유한다. 여러 OS 프로세스·호스트 사이에는 공유되지 않는다. +- 부분 체결분을 자동으로 최종 포지션에 합치는 완전한 broker reconciliation은 없다. HALT 후 + 운영자 대조가 필요하다. +- 중복 주문 가드는 사고 가능성을 낮추지만 브로커·DB·프로세스 전체를 아우르는 정확히 한 번 + 체결(exactly-once)을 보장하지 않는다. +- 백테스트와 paper 성과는 실전 성과가 아니다. 비용, 세금, 체결 지연, 시장 충격과 미래 시장 + 구조 변화 때문에 결과가 달라질 수 있다. + +이 한계 중 하나라도 현재 운영 방식에 수용 불가능하면 실전은 계속 NO-GO다. diff --git a/docs/images/dashboard-deposit.png b/docs/images/dashboard-deposit.png new file mode 100644 index 00000000..36f5a7fa Binary files /dev/null and b/docs/images/dashboard-deposit.png differ diff --git a/docs/images/dashboard-operations.png b/docs/images/dashboard-operations.png new file mode 100644 index 00000000..df083b50 Binary files /dev/null and b/docs/images/dashboard-operations.png differ diff --git a/docs/images/dashboard-overview.png b/docs/images/dashboard-overview.png new file mode 100644 index 00000000..fd9daa0d Binary files /dev/null and b/docs/images/dashboard-overview.png differ diff --git a/docs/images/dashboard-performance.png b/docs/images/dashboard-performance.png new file mode 100644 index 00000000..70aa0504 Binary files /dev/null and b/docs/images/dashboard-performance.png differ diff --git a/docs/images/dashboard-portfolio.png b/docs/images/dashboard-portfolio.png new file mode 100644 index 00000000..6efc5219 Binary files /dev/null and b/docs/images/dashboard-portfolio.png differ diff --git a/main.py b/main.py index 502583c0..8f629d60 100644 --- a/main.py +++ b/main.py @@ -158,6 +158,7 @@ def run_backtest(args): strategy_name=args.strategy, strict_lookahead=args.strict_lookahead, notify_overtrading=True, + symbol=args.symbol, ) if not result: @@ -653,6 +654,27 @@ def run_deploy_check(args) -> int: def run_rebalance(args): + """Run a rebalance, serializing every order-capable live invocation.""" + config = Config.get() + mode = str(config.trading.get("mode", "paper")).lower() + dry_run = bool(getattr(args, "dry_run", False)) + if mode != "live" or dry_run: + return _run_rebalance_impl(args) + + from core.runtime_lock import live_runtime_lock + + project_root = Path(__file__).resolve().parent + with live_runtime_lock(project_root) as acquired: + if not acquired: + logger.error( + "실전 리밸런싱 중단: 다른 live 스케줄러/리밸런서가 " + "실행 중이거나 프로세스 락을 확인할 수 없습니다." + ) + raise SystemExit(1) + return _run_rebalance_impl(args) + + +def _run_rebalance_impl(args): """바스켓 포트폴리오 리밸런싱 모드.""" from datetime import datetime from zoneinfo import ZoneInfo @@ -660,6 +682,7 @@ def run_rebalance(args): from core.notifier import Notifier from core.cycle_observability import ( detect_snapshot_gaps_for_account, + unreported_snapshot_gaps, format_gap_alert, record_cycle_event, ) @@ -670,6 +693,7 @@ def run_rebalance(args): dry_run = getattr(args, "dry_run", False) force_rebalance = getattr(args, "force_rebalance", False) mode = str(config.trading.get("mode", "paper")).lower() + ledger_mode = "live" if mode == "live" else "paper" live_rebalance_confirmed = False if basket_name: @@ -777,6 +801,42 @@ def run_rebalance(args): except Exception as guard_exc: logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc) + # 리스크 청산(손절/익절/트레일링)을 리밸런싱보다 먼저 평가한다. + # 그동안 이 사이클은 비중 교정만 했고 손절/익절은 장중 스케줄러 + # (core/scheduler.py)에만 있어서, 일 1회 리밸런싱으로만 굴러가는 바스켓 + # 트랙은 손절선을 뚫어도 아무 일도 일어나지 않았다(2026-08-07 점검에서 + # 9개 중 6개 포지션이 손절선 이탈 상태로 방치된 것을 확인). + # + # '1일 1매매 패스' 가드는 적용하지 않는다 — 그 가드는 회전율 상한 우회를 + # 막으려는 것이고, 리스크 청산은 회전 예산이 아니라 손실 제한이다. + # 청산이 이미 끝났으면 포지션이 없어 재평가가 비어 자연히 멱등이다. + try: + # list로 좁힌다 — 평가 결과가 주문 목록이 아니면 청산을 시도하지 않는다 + # (빈 목록과 '목록이 아닌 무언가'를 구분하지 않으면 유령 청산이 난다). + planned = rebalancer.plan_risk_exits() + exit_orders = list(planned) if isinstance(planned, (list, tuple)) else [] + except Exception as exit_exc: + exit_orders = [] + logger.error("바스켓 '{}' 리스크 청산 평가 실패: {}", name, exit_exc) + if exit_orders: + exit_result = rebalancer.execute( + exit_orders, + dry_run=dry_run, + live_confirmed=live_rebalance_confirmed, + ) + exit_summary = ( + f"🛡️ 바스켓 '{name}' 리스크 청산 {'(DRY RUN) ' if dry_run else ''}" + f"{exit_result['executed']}건 실행 / {exit_result['failed']}건 실패: " + + "; ".join(o.reason for o in exit_orders[:3]) + ) + logger.warning(exit_summary) + if not dry_run: + record_cycle_event( + "RISK_EXIT", exit_summary, severity="warning", + strategy=live_strategy_name, mode=mode, + ) + notifier.send_message(exit_summary, critical=True) + executed = False if already_traded_today: logger.info( @@ -858,6 +918,13 @@ def run_rebalance(args): gaps = detect_snapshot_gaps_for_account( config, live_strategy_name, now, ) + # 이미 알린 결측일은 거른다. 복구 불가능한 과거 결측은 매 사이클 + # 다시 감지되므로, 거르지 않으면 같은 하루가 매일 경보를 울려 + # 진짜 신호를 덮는다(8/18 결측 하나가 3주간 16건의 warning을 만든 + # 것을 2026-08-26 점검에서 확인). 커버리지 집계는 별개로 전체를 본다. + gaps = unreported_snapshot_gaps( + live_strategy_name, gaps, mode=mode, + ) if gaps: alert = format_gap_alert(name, gaps, today=now) today_missing = now.date() in gaps @@ -895,6 +962,7 @@ def run_rebalance(args): from core.portfolio_manager import twr_period_return snaps = get_portfolio_snapshots( days=7, account_key=live_strategy_name, + mode=ledger_mode, ) if snaps is not None and len(snaps) >= 2: sdf = snaps.sort_values("date") @@ -912,6 +980,7 @@ def run_rebalance(args): try: flow = get_cash_flow_total_between( live_strategy_name, prev_boundary, datetime.now(), + mode=ledger_mode, ) except Exception: pass @@ -1035,7 +1104,10 @@ def run_paper_trading(args): max_holding_days = (config.risk_params.get("position_limits", {}) or {}).get("max_holding_days", 0) if max_holding_days > 0: today = datetime.now().date() - for pos in get_all_positions(account_key=account_key if account_key else None): + for pos in get_all_positions( + account_key=account_key if account_key else None, + mode="paper", + ): bought_at = getattr(pos, "bought_at", None) if not bought_at: continue @@ -1089,7 +1161,11 @@ def run_paper_trading(args): except Exception: pass - if signal_info["signal"] == "BUY" and not get_position(symbol, account_key=account_key): + if signal_info["signal"] == "BUY" and not get_position( + symbol, + account_key=account_key, + mode="paper", + ): from core.market_regime import check_market_regime regime_result = check_market_regime(config, collector) if not regime_result["allow_buys"]: @@ -1117,7 +1193,11 @@ def run_paper_trading(args): ) if order_result.get("success"): discord.send_trade_alert(order_result) - elif signal_info["signal"] == "SELL" and get_position(symbol, account_key=account_key): + elif signal_info["signal"] == "SELL" and get_position( + symbol, + account_key=account_key, + mode="paper", + ): avg_vol = float(df["volume"].rolling(20, min_periods=1).mean().iloc[-1]) if "volume" in df.columns and not df["volume"].empty else None order_result = executor.execute_sell( symbol=symbol, @@ -1168,6 +1248,21 @@ def _check_live_readiness_gate(config, strategy_name: str) -> list[str]: def run_live_trading(args): + """Run the canonical live workflow under the global live runtime lock.""" + from core.runtime_lock import live_runtime_lock + + project_root = Path(__file__).resolve().parent + with live_runtime_lock(project_root) as acquired: + if not acquired: + logger.error( + "실전 모드 진입 중단: 다른 live 스케줄러/리밸런서가 " + "실행 중이거나 프로세스 락을 확인할 수 없습니다." + ) + raise SystemExit(1) + return _run_live_trading_impl(args) + + +def _run_live_trading_impl(args): """ 실전 매매 모드 실행. 4중 확인: 전략 코드 등록 → 환경변수 → --confirm-live → canonical live gate. @@ -1219,9 +1314,23 @@ def run_live_trading(args): config.enforce_live_auto_entry_policy() try: + # live에서는 승인 단위(전략)와 실계좌 라우팅이 1:1로 + # 명시되어야 한다. 기본 계좌 폴백은 장부별 노출 한도를 깨므로 + # 모드 플립 직후, 인증/잔고 조회 전에 친절한 오류로 종료한다. + try: + live_account_no = config.get_account_no(strategy_name) + except ValueError as exc: + logger.error("🚫 실전 계좌 라우팅 검증 실패: {}", exc) + logger.error( + "config/settings.yaml의 kis_api.accounts에 '{}'의 실계좌를 " + "명시한 뒤 다시 시도하세요.", + strategy_name, + ) + sys.exit(1) + # 토큰 사전 발급 (필수 환경변수 미설정 시 명확히 종료) from api.kis_api import KISApi - kis = KISApi() + kis = KISApi(account_no=live_account_no) if not kis.authenticate(): logger.error( "KIS API 인증 실패. 실전 모드를 사용하려면 " @@ -1423,25 +1532,67 @@ def run_compare_paper_backtest(args): def run_emergency_liquidate(args): - """긴급 전 종목 매도 (CLI: --mode liquidate). 블랙스완 감지 외에도 수동 개입이 필요할 때 즉시 전 종목 매도.""" - logger.info("=" * 50) - logger.info("🚨 긴급 전 종목 매도 모드") - logger.info("=" * 50) + """긴급 청산 대상을 명시적으로 paper/live로 고정해 실행한다. + canonical live 실행은 YAML을 paper로 유지한 채 해당 프로세스 안에서만 + mode를 live로 전환한다. 따라서 별도 청산 프로세스가 설정값만 보면 실제 + 계좌가 아니라 paper 장부를 지울 수 있다. CLI에서는 ``--liquidate-live``를 + 명시한 경우에만 live를 선택한다. 속성이 없는 기존 내부 호출은 하위 + 호환을 위해 설정 mode를 따르되, argparse 경로에는 항상 속성이 존재한다. + """ config = Config.get() - mode = str(config.trading.get("mode", "paper")).lower() + configured_mode = str(config.trading.get("mode", "paper")).lower() + explicit_live = getattr(args, "liquidate_live", None) + mode = configured_mode if explicit_live is None else ("live" if explicit_live else "paper") + if mode == "live": _require_live_operator_confirmation( args, action_label="실전 긴급 청산", - example="python main.py --mode liquidate --confirm-live", + example=( + "python main.py --mode liquidate --liquidate-live --confirm-live" + ), + ) + + old_mode = configured_mode + config.trading["mode"] = mode + try: + return _run_emergency_liquidate_impl(args, config=config, mode=mode) + finally: + config.trading["mode"] = old_mode + + +def _run_emergency_liquidate_impl(args, *, config, mode: str): + """선택된 장부와 브로커에서 전 종목을 청산한다.""" + logger.info("=" * 50) + logger.info("🚨 긴급 전 종목 매도 모드 ({})", mode) + logger.info("=" * 50) + + if mode == "live": + # 운영자 확인 직후, broker sync나 포지션 조회보다 먼저 전역 + # 영속 HALT를 남긴다. 청산 중/후 다른 프로세스가 신규 BUY를 + # 시도해도 OrderExecutor가 동일 DB 상태를 읽고 fail-closed한다. + from database.repositories import set_trading_halt + + halt_state = set_trading_halt( + "운영자 확인된 live 긴급 전량 청산 시작", + source="main.run_emergency_liquidate", + mode="live", + detail={ + "action": "emergency_liquidate", + "confirm_live": bool(getattr(args, "confirm_live", False)), + }, + ) + logger.critical( + "전역 거래 HALT 영속화 완료 (event_id={}) — 명시적 운영자 해제 전까지 BUY 차단", + halt_state.get("event_id"), ) _sync_live_positions_before_liquidation(config) from database.repositories import get_all_positions from core.order_executor import OrderExecutor - positions = get_all_positions() # 긴급 청산은 모든 계좌 포지션 대상 + positions = get_all_positions(mode=mode) # 선택한 장부의 모든 계좌 포지션 대상 summary = { "attempted": len(positions), "succeeded": 0, @@ -1467,26 +1618,26 @@ def run_emergency_liquidate(args): from api.kis_api import KISApi account_no = config.get_account_no(ak) kis = KISApi(account_no=account_no) - price_info = kis.get_current_price(pos.symbol) try: - price = float((price_info or {}).get("price") or 0) - except (TypeError, ValueError): - price = 0.0 - if price <= 0: - reason = "실전 긴급 청산 현재가 조회 실패" - summary["failed"] += 1 - summary["details"].append({ - "symbol": pos.symbol, - "account_key": ak, - "status": "failed", - "reason": reason, - }) - logger.error( - "{}: {} — 평균단가 fallback 매도를 실행하지 않습니다.", - reason, + price_info = kis.get_current_price(pos.symbol) + current_price = float((price_info or {}).get("price") or 0) + if current_price > 0: + price = current_price + else: + logger.critical( + "실전 긴급 청산 현재가 누락: {} — 평균단가 {:,.0f}원은 " + "체결 참조가로만 쓰고 broker 시장가 매도를 시도합니다.", + pos.symbol, + price, + ) + except Exception as exc: + logger.critical( + "실전 긴급 청산 현재가 조회 예외: {} ({}) — 평균단가 " + "{:,.0f}원을 체결 참조가로 broker 시장가 매도를 시도합니다.", pos.symbol, + exc, + price, ) - continue result = executor.execute_sell( pos.symbol, price, @@ -1732,12 +1883,15 @@ def _design_fraction(cfg: dict) -> float: key = rebalance_live_strategy_id(name) snap = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == key) + .filter( + PortfolioSnapshot.mode == "paper", + PortfolioSnapshot.account_key == key, + ) .order_by(PortfolioSnapshot.date.desc()) .first() ) last_dates.append(snap.date if snap else None) - positions_b = get_all_positions(account_key=key) or [] + positions_b = get_all_positions(account_key=key, mode="paper") or [] position_count += len(positions_b) # 배치율은 부가 신호 — 계산 실패(예: baskets.yaml에 float 불가한 # target_stock_weight 오타)가 핵심 신호인 결측/staleness 감지를 @@ -1781,6 +1935,48 @@ def _design_fraction(cfg: dict) -> float: None if (not last_dates or any(d is None for d in last_dates)) else min(last_dates) ) + # 적립 계획 이행 점검 — 적립식 트랙은 입금이 멈추면 '주문 실패 0건'인 채로 + # 설계가 굴러가지 않는다(잔고가 1주 단위를 못 넘겨 배치율이 수렴 불가). + contribution_notes: list[str] = [] + try: + from core.operator_health import summarize_contribution_plan + from database.repositories import get_cash_flows + + for name in enabled_baskets: + cfg_b = baskets_cfg.get(name) or {} + plan = cfg_b.get("contribution_plan") + if not plan: + continue + key = _rebalance_live_strategy_id(name) + flows = get_cash_flows(account_key=key, mode="paper") + last_flow = max( + (getattr(f, "occurred_at", None) for f in flows if + getattr(f, "occurred_at", None) is not None), default=None, + ) + # 트랙 개시일 = 이 계정의 첫 스냅샷. 개시 직후에는 아직 적립 시점이 + # 오지 않았을 수 있으므로 판정에 필요하다. + sess = get_session() + try: + row = ( + sess.query(PortfolioSnapshot.date) + .filter( + PortfolioSnapshot.mode == "paper", + PortfolioSnapshot.account_key == key, + ) + .order_by(PortfolioSnapshot.date.asc()) + .first() + ) + finally: + sess.close() + first_snap = row[0] if row else None + plan_state = summarize_contribution_plan( + name, plan, last_flow, first_snap, date.today(), + ) + if plan_state["note"]: + contribution_notes.append(plan_state["note"]) + except Exception as plan_exc: + logger.debug("적립 계획 점검 생략: {}", plan_exc) + basket_operation = { "enabled_baskets": enabled_baskets, "last_snapshot_date": oldest_last, @@ -1789,6 +1985,7 @@ def _design_fraction(cfg: dict) -> float: "deployment_ratio": worst_dep_ratio, "design_fraction": worst_design, "deployment_tolerance": worst_tolerance, + "contribution_notes": contribution_notes, } except Exception as exc: logger.warning("바스켓 운영 상태 조회 실패: {}", exc) @@ -1819,6 +2016,28 @@ def _design_fraction(cfg: dict) -> float: return {"OK": 0, "ATTENTION": 1, "BLOCKED": 2}.get(health["verdict"], 1) +def _fetch_benchmark_closes(start, end) -> dict: + """벤치마크(KS11) 종가를 {date: close}로. 실패하면 빈 dict. + + 일간 수익률이 아니라 **종가 레벨**을 준다 — 스냅샷이 빠진 날이 있으면 NAV 수익률은 + 여러 날 구간이 되므로, 벤치마크도 같은 구간으로 다시 계산해야 비교가 성립한다 + (core.performance_lens.aligned_returns 참고). + """ + from datetime import timedelta + + try: + import FinanceDataReader as fdr + + s = (start.date() if hasattr(start, "date") else start) - timedelta(days=7) + e = end.date() if hasattr(end, "date") else end + df = fdr.DataReader("KS11", s.isoformat(), e.isoformat()) + if df is None or df.empty or "Close" not in df.columns: + return {} + return {idx.date(): float(v) for idx, v in df["Close"].items()} + except Exception: + return {} + + def run_weekly_report() -> int: """주간 요약 리포트 — 판단 주기(주 1회) 다이제스트를 Discord로 발송. @@ -1858,7 +2077,10 @@ def _d(v): try: snaps = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == key) + .filter( + PortfolioSnapshot.mode == "paper", + PortfolioSnapshot.account_key == key, + ) .order_by(PortfolioSnapshot.date.asc()) .all() ) @@ -1897,7 +2119,12 @@ def _d(v): try: ref_boundary = ref.created_at or ref.date last_boundary = snaps[-1].created_at or now_kst - flow = get_cash_flow_total_between(key, ref_boundary, last_boundary) + flow = get_cash_flow_total_between( + key, + ref_boundary, + last_boundary, + mode="paper", + ) except Exception: pass week_change = twr_period_return(ref_val, last_val, flow) * 100 @@ -1911,10 +2138,40 @@ def _d(v): except Exception: missing_days = 0 + # 국면 분해 + 리스크 지표 — 수익률 한 숫자로는 '방어의 대가'가 안 보인다 + # (docs/OPERATING_PRINCIPLES.md 원칙 9). 스냅샷의 daily_return과 같은 날의 + # 벤치마크 일간 수익률을 짝지어 상승/하락 국면을 나눠 잰다. + regime = risk = None + try: + from core.performance_lens import ( + aligned_returns, daily_returns_from_nav, risk_metrics, + split_by_regime, + ) + + # 스냅샷의 daily_return 열이 아니라 NAV 시계열에서 직접 뽑는다 — + # 그 열은 2026-08-10 이전 전 구간이 0.0이라(값을 안 넘기던 버그) + # 그대로 쓰면 변동성이 0으로 깔려 없는 안정성을 주장하게 된다. + nav_points = [(s.date, s.total_value) for s in snaps] + daily = daily_returns_from_nav(nav_points) + risk = risk_metrics([r for _, r in daily]) + if len(nav_points) >= 2: + closes = _fetch_benchmark_closes( + nav_points[0][0], nav_points[-1][0], + ) + if closes: + pairs = [ + (m, b) for _d, m, b in aligned_returns(nav_points, closes) + ] + if pairs: + regime = split_by_regime(pairs) + except Exception as lens_exc: + logger.debug("국면/리스크 지표 생략: {}", lens_exc) + summary = build_weekly_summary( basket_name=basket_name, eval_result=eval_result, week_nav_change_pct=week_change, missing_days=missing_days, cycle_errors=cycle_errors, + regime=regime, risk=risk, ) logger.info("\n{}", summary["text"]) try: @@ -2100,6 +2357,14 @@ def main(): "--confirm-live", action="store_true", help="실전 모드 진입 시 필수. 미지정 시 live 모드 진입 거부.", ) + parser.add_argument( + "--liquidate-live", + action="store_true", + help=( + "[liquidate 모드] 실계좌 청산 대상을 명시. " + "ENABLE_LIVE_TRADING=true 및 --confirm-live도 함께 필요" + ), + ) # --force-live 제거됨 (감사 C-1 대응): hard gate는 우회 불가 parser.add_argument( "--output-dir", type=str, default="reports", diff --git a/monitoring/dashboard.py b/monitoring/dashboard.py index 09e8edbc..37923b7a 100644 --- a/monitoring/dashboard.py +++ b/monitoring/dashboard.py @@ -23,6 +23,11 @@ class Dashboard: def __init__(self, config: Config = None): self.config = config or Config.get() + self.ledger_mode = ( + "live" + if str(self.config.trading.get("mode", "paper")).lower() == "live" + else "paper" + ) self.initial_capital = self.config.risk_params.get( "position_sizing", {} ).get("initial_capital", 10000000) @@ -77,7 +82,7 @@ def show_recent_snapshots(self, days: int = 7): Args: days: 조회 기간 (일) """ - snapshots = get_portfolio_snapshots(days) + snapshots = get_portfolio_snapshots(days, mode=self.ledger_mode) if snapshots.empty: print(" 스냅샷 데이터 없음") diff --git a/monitoring/liquidate_trigger.py b/monitoring/liquidate_trigger.py index 59481638..b6c1aa50 100644 --- a/monitoring/liquidate_trigger.py +++ b/monitoring/liquidate_trigger.py @@ -111,7 +111,11 @@ def _run_liquidate() -> tuple[bool, str]: from main import run_emergency_liquidate - args = Namespace(confirm_live=_env_truthy("LIQUIDATE_TRIGGER_CONFIRM_LIVE")) + confirmed_live = _env_truthy("LIQUIDATE_TRIGGER_CONFIRM_LIVE") + args = Namespace( + confirm_live=confirmed_live, + liquidate_live=confirmed_live, + ) try: result = run_emergency_liquidate(args) except SystemExit as exc: diff --git a/monitoring/paper_monitor.py b/monitoring/paper_monitor.py index 04d08e27..8900b521 100644 --- a/monitoring/paper_monitor.py +++ b/monitoring/paper_monitor.py @@ -245,6 +245,7 @@ def _get_snapshots(self, start: datetime, end: datetime) -> list: session = get_session() try: q = session.query(PortfolioSnapshot).filter( + PortfolioSnapshot.mode == self.mode, PortfolioSnapshot.date >= start, PortfolioSnapshot.date <= end, ) @@ -317,6 +318,7 @@ def check(self) -> dict: try: # 스냅샷 기간 확인 snapshots = session.query(PortfolioSnapshot).filter( + PortfolioSnapshot.mode == "paper", PortfolioSnapshot.account_key == (self.account_key or ""), ).order_by(PortfolioSnapshot.date).all() diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css new file mode 100644 index 00000000..1bd0ae3a --- /dev/null +++ b/monitoring/static/dashboard.css @@ -0,0 +1,1711 @@ +:root { + color-scheme: light; + --paper: #f4f0e7; + --paper-deep: #e9e3d7; + --surface: #fffdf8; + --surface-muted: #f8f5ee; + --ink: #18201f; + --ink-soft: #34413e; + --muted: #5d6966; + --faint: #626d69; + --line: #d8d5cc; + --line-strong: #b9b7af; + --oxide: #b84a2a; + --oxide-dark: #8f351d; + --oxide-soft: #f3dfd6; + --positive: #2d6f5e; + --positive-soft: #dfece6; + --negative: #a52e43; + --negative-soft: #f4dfe3; + --warning: #8a5a14; + --warning-soft: #f2e6cf; + --info: #4c668f; + --info-soft: #e2e8f1; + --focus: #2f65bd; + --shadow-dialog: 0 24px 70px rgb(24 32 31 / 22%); + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --content-width: 1240px; + --font-sans: "IBM Plex Sans KR", Pretendard, "Segoe UI", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +html { + scroll-behavior: smooth; + scrollbar-color: var(--line-strong) var(--paper); + background: var(--paper); +} + +body { + margin: 0; + min-width: 320px; + min-height: 100dvh; + overflow-x: hidden; + background: var(--paper); + color: var(--ink); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.55; + letter-spacing: -0.012em; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: rgb(184 74 42 / 14%); +} + +button, +select, +input { + font: inherit; +} + +button, +a, +select, +summary { + touch-action: manipulation; +} + +button, +select, +summary, +a { + cursor: pointer; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible, +summary:focus-visible, +a:focus-visible, +[tabindex="0"]:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.skip-link { + position: fixed; + top: 10px; + left: 10px; + z-index: 1000; + padding: 10px 14px; + transform: translateY(-160%); + background: var(--ink); + color: #fff; + border-radius: var(--radius-sm); + font-weight: 700; + transition: transform 160ms ease-out; +} + +.skip-link:focus { + transform: translateY(0); +} + +.site-header { + position: sticky; + top: 0; + z-index: 50; + border-bottom: 1px solid rgb(24 32 31 / 12%); + background: rgb(244 240 231 / 94%); + backdrop-filter: blur(16px) saturate(1.1); +} + +.header-inner { + width: min(100%, var(--content-width)); + min-height: 72px; + margin: 0 auto; + padding: 12px 24px; + display: grid; + grid-template-columns: minmax(180px, auto) 1fr auto; + align-items: center; + gap: 24px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + min-height: 44px; + min-width: 0; + color: var(--ink); + text-decoration: none; +} + +.brand img { + flex: 0 0 auto; +} + +.brand-copy { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.brand-copy strong { + font-size: 1.2rem; + font-weight: 700; + letter-spacing: -0.045em; +} + +.brand-copy span { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.67rem; + font-weight: 600; + letter-spacing: 0.13em; +} + +.header-state { + justify-self: end; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--muted); + font-size: 0.76rem; +} + +.header-state time { + color: var(--faint); + font-family: var(--font-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +.sync-mark { + width: 9px; + height: 9px; + flex: 0 0 auto; + border: 1px solid currentColor; + background: transparent; +} + +.sync-mark[data-state="ok"] { + color: var(--positive); + background: var(--positive); +} + +.sync-mark[data-state="partial"] { + color: var(--warning); + background: var(--warning); +} + +.sync-mark[data-state="error"] { + color: var(--negative); + background: var(--negative); +} + +.sync-mark[data-state="loading"] { + color: var(--info); + animation: sync-blink 1.2s steps(2, jump-none) infinite; +} + +@keyframes sync-blink { + 50% { opacity: 0.35; } +} + +.header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.mode-badge { + min-height: 32px; + display: inline-flex; + align-items: center; + padding: 5px 9px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-xs); + background: var(--surface); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.mode-badge[data-mode="paper"] { + border-color: #9ab0a8; + background: var(--positive-soft); + color: #1f584a; +} + +.mode-badge[data-mode="live"] { + border-color: #c9808c; + background: var(--negative-soft); + color: #7d1f31; +} + +.button { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-weight: 650; + line-height: 1; + transition: background-color 160ms ease-out, border-color 160ms ease-out, color 160ms ease-out, transform 120ms ease-out; +} + +.button:active { + transform: translateY(1px); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.button-primary { + border-color: var(--oxide); + background: var(--oxide); + color: #fff; +} + +.button-primary:hover:not(:disabled) { + border-color: var(--oxide-dark); + background: var(--oxide-dark); +} + +.button-secondary { + border-color: var(--line-strong); + background: var(--surface); + color: var(--ink); +} + +.button-secondary:hover:not(:disabled) { + border-color: var(--ink-soft); + background: var(--surface-muted); +} + +.page-shell { + width: min(100%, var(--content-width)); + margin: 0 auto; + padding: 36px 24px 64px; +} + +.decision-panel { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); + gap: 36px; + align-items: end; + min-height: 310px; + padding: 40px; + overflow: hidden; + border: 1px solid var(--ink); + border-radius: var(--radius-md); + background: var(--ink); + color: var(--paper); +} + +.decision-panel::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 86px; + height: 8px; + background: var(--oxide); +} + +.eyebrow, +.section-index { + margin: 0 0 10px; + color: var(--oxide); + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.decision-panel .eyebrow { + color: #e68b6d; +} + +.decision-copy h1 { + max-width: 16ch; + margin: 0; + font-size: clamp(2rem, 4vw, 4rem); + font-weight: 600; + line-height: 1.08; + letter-spacing: -0.055em; + text-wrap: balance; + word-break: keep-all; +} + +.decision-copy > p:not(.eyebrow) { + max-width: 54ch; + margin: 18px 0 0; + color: #c9cfcb; + font-size: 1rem; + line-height: 1.7; + text-wrap: pretty; +} + +.decision-meta { + min-height: 24px; + margin-top: 18px; + color: #9eaaa5; + font-size: 0.8rem; +} + +.decision-meta strong { + color: var(--paper); +} + +.text-action { + min-height: 44px; + margin-top: 18px; + padding: 0; + border: 0; + border-bottom: 1px solid #df8b6f; + background: transparent; + color: #f3ad94; + font-weight: 650; +} + +.text-action:hover { + color: #fff; + border-color: #fff; +} + +.summary-ledger { + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid #4d5754; + border-left: 1px solid #4d5754; +} + +.summary-ledger > div { + min-height: 104px; + padding: 18px; + border-right: 1px solid #4d5754; + border-bottom: 1px solid #4d5754; +} + +.summary-ledger dt { + margin-bottom: 12px; + color: #9eaaa5; + font-size: 0.74rem; +} + +.summary-ledger dd { + margin: 0; + color: #fff; + font-family: var(--font-mono); + font-size: clamp(1.05rem, 2vw, 1.42rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.035em; +} + +.summary-ledger dd.positive { color: #7fc2ad; } +.summary-ledger dd.negative { color: #f093a2; } + +.section-nav { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin: 18px 0 0; + padding: 13px 0; + border-bottom: 1px solid var(--line-strong); +} + +.section-nav a { + min-height: 44px; + display: inline-flex; + align-items: center; + color: var(--muted); + font-size: 0.82rem; + font-weight: 600; + text-decoration: none; + text-underline-offset: 5px; +} + +.section-nav a:hover { + color: var(--oxide); + text-decoration: underline; +} + +.content-section { + scroll-margin-top: 94px; + padding-top: 74px; +} + +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.section-heading h2 { + margin: 0; + font-size: clamp(1.55rem, 2.8vw, 2.4rem); + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.045em; + text-wrap: balance; +} + +.section-heading p:not(.section-index) { + max-width: 62ch; + margin: 8px 0 0; + color: var(--muted); + font-size: 0.9rem; + text-wrap: pretty; +} + +.section-asof { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.track-list { + display: grid; + gap: 14px; +} + +.track-card { + position: relative; + display: grid; + grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.45fr); + gap: 32px; + padding: 26px 28px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.track-card[data-primary="true"] { + border-left: 6px solid var(--oxide); + padding-left: 23px; +} + +.track-card[data-primary="false"] { + background: var(--surface-muted); +} + +.track-role { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.role-label, +.status-label { + display: inline-flex; + min-height: 26px; + align-items: center; + padding: 3px 7px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-xs); + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.role-label.primary { + border-color: #d39781; + background: var(--oxide-soft); + color: var(--oxide-dark); +} + +.track-title { + margin: 0; + font-size: 1.08rem; + font-weight: 650; + letter-spacing: -0.025em; +} + +.track-id { + display: block; + margin-top: 4px; + color: var(--faint); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.track-value { + margin-top: 22px; + font-family: var(--font-mono); + font-size: clamp(1.75rem, 4vw, 2.8rem); + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.06em; + line-height: 1; +} + +.track-value small { + margin-left: 5px; + color: var(--muted); + font-family: var(--font-sans); + font-size: 0.8rem; + font-weight: 500; +} + +.track-principal, +.track-date { + margin-top: 8px; + color: var(--muted); + font-size: 0.76rem; +} + +.track-date { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.track-plan { + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid var(--line); + color: var(--ink-soft); + font-size: 0.76rem; + font-weight: 600; +} + +.track-metrics { + margin: 0; + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.track-metrics > div { + min-width: 0; + min-height: 88px; + padding: 14px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.track-metrics dt { + margin-bottom: 8px; + color: var(--muted); + font-size: 0.7rem; +} + +.track-metrics dd { + margin: 0; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 0.92rem; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.positive { color: var(--positive) !important; } +.negative { color: var(--negative) !important; } +.warning { color: var(--warning) !important; } +.muted { color: var(--muted) !important; } + +.allocation { + margin-top: 16px; +} + +.allocation-copy { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 7px; + color: var(--muted); + font-size: 0.72rem; +} + +.allocation-copy strong { + color: var(--ink-soft); + font-family: var(--font-mono); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.allocation-bar { + height: 8px; + overflow: hidden; + border: 1px solid var(--line); + background: var(--paper-deep); +} + +.allocation-bar span { + display: block; + height: 100%; + background: var(--ink-soft); + transition: width 260ms ease-out; +} + +.allocation-note { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.55; +} + +.holdings { + margin: 16px 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 7px; + list-style: none; +} + +.holdings li { + padding: 5px 8px; + border-left: 2px solid var(--line-strong); + background: var(--surface-muted); + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +.recent-flow { + margin: 14px 0 0; + color: var(--muted); + font-size: 0.73rem; +} + +.performance-layout { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(310px, 0.7fr); + gap: 16px; + align-items: start; +} + +.module { + min-width: 0; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.module-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 24px; + padding: 22px 22px 18px; + border-bottom: 1px solid var(--line); +} + +.module-header h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.02em; +} + +.module-header p { + margin: 5px 0 0; + color: var(--muted); + font-size: 0.76rem; +} + +.chart-controls { + display: grid; + grid-template-columns: auto minmax(150px, auto); + align-items: center; + gap: 7px 10px; +} + +.chart-controls > label { + color: var(--muted); + font-size: 0.7rem; + font-weight: 600; +} + +select, +input { + min-height: 44px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font-size: 0.9rem; +} + +select { + padding: 8px 34px 8px 10px; +} + +.range-group, +.preset-group { + margin: 0; + padding: 0; + border: 0; +} + +.range-group { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.range-group legend, +.preset-group legend { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.range-group button { + min-height: 44px; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: var(--radius-xs); + background: transparent; + color: var(--muted); + font-size: 0.7rem; + font-weight: 600; + transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out; +} + +.range-group button:hover, +.range-group button[aria-pressed="true"] { + border-color: var(--line-strong); + background: var(--surface-muted); + color: var(--ink); +} + +.chart-figure { + margin: 0; + padding: 18px 22px 14px; +} + +.chart-wrap { + min-height: 340px; + position: relative; +} + +.chart-wrap canvas { + display: block; + max-width: 100%; +} + +.chart-maturity { + margin-top: 7px !important; + color: var(--oxide) !important; + font-size: 0.75rem !important; + font-weight: 600; +} + +.chart-figure figcaption { + margin-top: 12px; + color: var(--faint); + font-size: 0.75rem; +} + +.empty-state { + min-height: 340px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + border: 1px dashed var(--line-strong); + color: var(--muted); + text-align: center; +} + +.empty-state strong { + margin-bottom: 6px; + color: var(--ink); +} + +.data-disclosure { + margin: 0 22px 22px; + border-top: 1px solid var(--line); +} + +.data-disclosure summary { + min-height: 44px; + display: flex; + align-items: center; + color: var(--muted); + font-size: 0.74rem; + font-weight: 600; +} + +.review-module { + scroll-margin-top: 94px; +} + +.review-list { + margin: 0; + padding: 0; + list-style: none; +} + +.review-item { + padding: 18px 22px; + border-bottom: 1px solid var(--line); +} + +.review-item:last-child { + border-bottom: 0; +} + +.review-head { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 0.82rem; +} + +.review-head strong { + overflow-wrap: anywhere; +} + +.review-state { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.67rem; + white-space: nowrap; +} + +.review-progress { + height: 7px; + margin-top: 12px; + overflow: hidden; + border: 1px solid var(--line); + background: var(--paper-deep); +} + +.review-progress span { + display: block; + height: 100%; + background: var(--info); + transition: width 260ms ease-out; +} + +.review-detail { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 8px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.66rem; + font-variant-numeric: tabular-nums; +} + +.review-issues { + margin: 10px 0 0; + padding-left: 17px; + color: var(--negative); + font-size: 0.7rem; +} + +.module-note { + margin: 0; + padding: 16px 22px; + border-top: 1px solid var(--line); + background: var(--surface-muted); + color: var(--muted); + font-size: 0.72rem; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.trust-grid.compact { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.trust-card { + min-height: 104px; + padding: 16px; + border-top: 3px solid var(--line-strong); + background: var(--surface); +} + +.trust-card[data-state="ok"] { border-color: var(--positive); } +.trust-card[data-state="warning"] { border-color: var(--warning); } +.trust-card[data-state="error"] { border-color: var(--negative); } +.trust-card[data-state="info"] { border-color: var(--info); } + +.trust-card .label { + display: block; + margin-bottom: 9px; + color: var(--muted); + font-size: 0.7rem; +} + +.trust-card .value { + display: block; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 0.94rem; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.trust-card .support { + display: block; + margin-top: 7px; + color: var(--faint); + font-size: 0.75rem; +} + +.runtime-meta { + min-height: 20px; + margin: 12px 0 0; + color: var(--muted); + font-size: 0.72rem; +} + +.halt-guidance { + margin-top: 18px; + padding: 20px; + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(300px, 1.2fr) auto; + align-items: center; + gap: 24px; + border: 1px solid var(--negative); + border-left-width: 5px; + background: var(--negative-soft); +} + +.halt-guidance[hidden] { + display: none; +} + +.halt-guidance h3, +.halt-guidance p, +.halt-guidance ol { + margin: 0; +} + +.halt-guidance h3 { + margin-top: 5px; + font-size: 1rem; +} + +.halt-guidance #haltGuidanceReason { + margin-top: 8px; + color: var(--ink-soft); + font-size: 0.8rem; +} + +.halt-guidance ol { + padding-left: 20px; + color: var(--ink-soft); + font-size: 0.78rem; + line-height: 1.7; +} + +.halt-guidance a { + min-height: 44px; + display: inline-flex; + align-items: center; + color: var(--negative); + font-size: 0.78rem; + font-weight: 700; + text-underline-offset: 4px; +} + +.primary-inline { + display: inline-block; + margin-left: 7px; + padding: 2px 5px; + border: 1px solid var(--oxide); + color: var(--oxide); + font-size: 0.62rem; + font-weight: 700; + vertical-align: 1px; +} + +.disclosure-stack { + display: grid; + gap: 10px; + margin-top: 24px; +} + +.disclosure { + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.disclosure > summary { + min-height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 14px 18px; + list-style: none; +} + +.disclosure > summary::-webkit-details-marker { + display: none; +} + +.disclosure > summary::after { + content: "+"; + margin-left: auto; + color: var(--oxide); + font-family: var(--font-mono); + font-size: 1.2rem; + font-weight: 500; +} + +.disclosure[open] > summary::after { + content: "−"; +} + +.disclosure > summary:hover { + background: var(--surface-muted); +} + +.disclosure summary span:first-child { + display: flex; + flex-direction: column; + gap: 2px; +} + +.disclosure summary strong { + color: var(--ink); + font-size: 0.86rem; +} + +.disclosure summary small { + color: var(--muted); + font-size: 0.68rem; +} + +.summary-count { + margin-left: auto; + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.disclosure-body { + padding: 18px; + border-top: 1px solid var(--line); +} + +.disclosure-body h4 { + margin: 26px 0 12px; + font-size: 0.86rem; +} + +.table-scroll { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; +} + +table { + width: 100%; + min-width: 640px; + border-collapse: collapse; + font-size: 0.78rem; +} + +caption { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +th, +td { + padding: 11px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +th { + color: var(--muted); + font-size: 0.67rem; + font-weight: 600; +} + +td { + font-variant-numeric: tabular-nums; +} + +.num { + text-align: right; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.empty-inline, +.error-inline, +.loading-line, +.loading-card { + margin: 0; + padding: 18px; + color: var(--muted); + font-size: 0.78rem; +} + +.error-inline { + border-left: 3px solid var(--negative); + background: var(--negative-soft); + color: #7d1f31; +} + +.loading-card { + min-height: 140px; + display: flex; + align-items: center; + border: 1px solid var(--line-strong); + background: var(--surface); +} + +.site-footer { + display: grid; + grid-template-columns: auto 1fr minmax(280px, 0.8fr); + gap: 14px; + align-items: center; + margin-top: 80px; + padding: 28px 0 0; + border-top: 1px solid var(--line-strong); + color: var(--muted); + font-size: 0.72rem; +} + +.site-footer p { + margin: 0; +} + +.site-footer strong { + color: var(--ink); +} + +.footer-note { + justify-self: end; + max-width: 52ch; + text-align: right; +} + +.deposit-dialog { + width: min(520px, calc(100vw - 32px)); + max-height: min(760px, calc(100dvh - 32px)); + margin: auto; + padding: 0; + overflow: auto; + overscroll-behavior: contain; + border: 1px solid var(--ink); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-dialog); +} + +.deposit-dialog::backdrop { + background: rgb(24 32 31 / 60%); + backdrop-filter: blur(3px); +} + +.deposit-dialog form { + padding: 24px; +} + +.dialog-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 24px; +} + +.dialog-header h2 { + margin: 0; + font-size: 1.45rem; + letter-spacing: -0.035em; +} + +.icon-button { + width: 44px; + height: 44px; + display: inline-grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + transition: background-color 150ms ease-out, border-color 150ms ease-out; +} + +.icon-button:hover { + border-color: var(--ink); + background: var(--surface-muted); +} + +.dialog-description { + margin: 14px 0 22px; + color: var(--muted); + font-size: 0.82rem; + text-wrap: pretty; +} + +.field { + margin-bottom: 18px; +} + +.field > label { + display: block; + margin-bottom: 7px; + color: var(--ink-soft); + font-size: 0.78rem; + font-weight: 600; +} + +.field > label span { + color: var(--faint); + font-weight: 400; +} + +.field input, +.field select { + width: 100%; + padding: 10px 12px; + font-size: 16px; +} + +.field small { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 0.7rem; +} + +.money-input { + position: relative; +} + +.money-input input { + padding-right: 42px; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.money-input > span { + position: absolute; + top: 50%; + right: 13px; + transform: translateY(-50%); + color: var(--muted); + font-size: 0.76rem; +} + +.preset-group { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; + margin-top: 10px; +} + +.preset-group button { + min-height: 44px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink-soft); + font-size: 0.78rem; + font-weight: 600; + transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out; +} + +.preset-group button:hover, +.preset-group button[aria-pressed="true"] { + border-color: var(--oxide); + background: var(--oxide-soft); + color: var(--oxide-dark); +} + +.deposit-confirm { + padding: 16px; + border: 1px solid var(--line-strong); + background: var(--surface-muted); +} + +.deposit-confirm > p { + margin: 0 0 12px; + color: var(--muted); + font-size: 0.76rem; +} + +.deposit-confirm dl { + margin: 0; +} + +.deposit-confirm dl > div { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 8px 0; + border-top: 1px solid var(--line); +} + +.deposit-confirm dt { + color: var(--muted); + font-size: 0.74rem; +} + +.deposit-confirm dd { + margin: 0; + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 500; + text-align: right; +} + +.form-error { + margin: 14px 0 0; + padding: 10px 12px; + border-left: 3px solid var(--negative); + background: var(--negative-soft); + color: #7d1f31; + font-size: 0.76rem; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +} + +.toast { + position: fixed; + left: 50%; + bottom: max(24px, env(safe-area-inset-bottom)); + z-index: 200; + max-width: min(560px, calc(100vw - 32px)); + padding: 12px 16px; + transform: translate(-50%, 18px); + border: 1px solid var(--ink); + border-radius: var(--radius-sm); + background: var(--ink); + color: #fff; + box-shadow: 0 12px 32px rgb(24 32 31 / 18%); + font-size: 0.8rem; + opacity: 0; + pointer-events: none; + transition: opacity 180ms ease-out, transform 180ms ease-out; +} + +.toast.show { + transform: translate(-50%, 0); + opacity: 1; +} + +.toast[data-kind="error"] { + border-color: var(--negative); + background: #6d1d2c; +} + +@media (max-width: 1040px) { + .header-inner { + grid-template-columns: auto 1fr; + } + + .header-state { + order: 3; + grid-column: 1 / -1; + justify-self: stretch; + padding-top: 8px; + border-top: 1px solid var(--line); + } + + .header-actions { + justify-self: end; + } + + .decision-panel { + grid-template-columns: 1fr; + } + + .summary-ledger { + max-width: 720px; + } + + .performance-layout { + grid-template-columns: 1fr; + } + + .track-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .trust-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .halt-guidance { + grid-template-columns: 1fr; + gap: 14px; + } +} + +@media (max-width: 760px) { + .site-header { + position: relative; + backdrop-filter: none; + } + + .header-inner { + grid-template-columns: 1fr; + gap: 10px; + padding: 12px 16px 14px; + } + + .brand, + .header-actions, + .header-state { + justify-self: stretch; + } + + .header-actions { + display: grid; + grid-template-columns: auto 1fr; + } + + .header-actions .button { + width: 100%; + } + + .header-state { + grid-column: auto; + order: initial; + flex-wrap: wrap; + } + + .header-state time { + margin-left: 17px; + flex-basis: 100%; + } + + .page-shell { + padding: 20px 16px 48px; + } + + .decision-panel { + min-height: 0; + padding: 32px 22px 22px; + gap: 28px; + } + + .decision-copy h1 { + max-width: 18ch; + font-size: clamp(2rem, 10vw, 3rem); + } + + .summary-ledger > div { + min-height: 92px; + padding: 14px; + } + + .section-nav { + gap: 4px 14px; + } + + .content-section { + scroll-margin-top: 20px; + padding-top: 56px; + } + + .section-heading { + align-items: start; + flex-direction: column; + gap: 14px; + } + + .section-heading .button { + width: 100%; + } + + .track-card { + grid-template-columns: 1fr; + gap: 24px; + padding: 22px 18px; + } + + .track-card[data-primary="true"] { + padding-left: 13px; + } + + .track-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .module-header { + flex-direction: column; + } + + .chart-controls { + width: 100%; + grid-template-columns: 1fr; + } + + .chart-controls > label, + .chart-controls > select { + width: 100%; + } + + .range-group { + grid-column: auto; + display: grid; + grid-template-columns: repeat(4, 1fr); + } + + .range-group button { + min-height: 44px; + } + + .chart-figure { + padding: 16px 12px 12px; + } + + .chart-wrap, + .empty-state { + min-height: 280px; + } + + .data-disclosure { + margin: 0 12px 16px; + } + + .trust-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .disclosure > summary { + min-height: 78px; + padding: 14px; + } + + .disclosure-body { + padding: 12px; + } + + .site-footer { + grid-template-columns: auto 1fr; + } + + .footer-note { + grid-column: 1 / -1; + justify-self: start; + text-align: left; + } + + .deposit-dialog form { + padding: 20px 16px; + } +} + +@media (max-width: 420px) { + .brand-copy span { + display: none; + } + + .summary-ledger > div { + min-height: 78px; + } + + .trust-grid { + grid-template-columns: 1fr; + } + + .track-metrics > div { + min-height: 76px; + } + + .range-group { + grid-template-columns: repeat(2, 1fr); + } + + .dialog-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .dialog-actions .button-primary { + grid-column: 1 / -1; + grid-row: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +@media (prefers-contrast: more) { + :root { + --line: #999b95; + --line-strong: #666b68; + --muted: #3f4946; + --faint: #505b57; + } +} diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js new file mode 100644 index 00000000..19f93879 --- /dev/null +++ b/monitoring/static/dashboard.js @@ -0,0 +1,1234 @@ +"use strict"; + +const $ = (id) => document.getElementById(id); +const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }); +const compactWon = new Intl.NumberFormat("ko-KR", { + notation: "compact", + maximumFractionDigits: 1, +}); +const dateOnly = new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", +}); +const dateShort = new Intl.DateTimeFormat("ko-KR", { + month: "short", + day: "numeric", +}); +const dateTime = new Intl.DateTimeFormat("ko-KR", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +const query = new URLSearchParams(window.location.search); +const requestedDays = Number(query.get("days")); +const allowedDays = [30, 90, 365, 3650]; + +const state = { + mode: "unknown", + baskets: null, + evaluations: null, + runtime: null, + legacy: null, + flows: new Map(), + flowStatus: new Map(), + flowError: false, + chartRows: [], + chartDays: allowedDays.includes(requestedDays) ? requestedDays : 90, + chartAccount: query.get("account") || null, + lastCoreSuccess: null, + coreError: null, + coreStatus: "loading", + runtimeStatus: "loading", + activeRequests: new Map(), + depositConfirming: false, + depositRequestId: null, +}; + +const elements = { + basketTracks: $("basketTracks"), + portfolioSummary: $("portfolioSummary"), + portfolioAsOf: $("portfolioAsOf"), + chartAccount: $("chartAccount"), + chartSummary: $("chartSummary"), + chartMaturity: $("chartMaturity"), + chartWrap: $("chartWrap"), + chartEmpty: $("chartEmpty"), + chartRows: $("chartDataRows"), + basketEval: $("basketEval"), + runtimeOps: $("runtimeOps"), + runtimeMeta: $("runtimeMeta"), + syncMark: $("syncMark"), + syncStatus: $("syncStatus"), + lastUpdate: $("lastUpdate"), + modeBadge: $("modeBadge"), + decisionTitle: $("decisionTitle"), + decisionDescription: $("decisionDescription"), + decisionMeta: $("decisionMeta"), + decisionAction: $("decisionAction"), + openDeposit: $("openDepositButton"), + depositAvailability: $("depositAvailability"), + depositDialog: $("depositDialog"), + depositForm: $("depositForm"), + depositFields: $("depositFields"), + depositConfirm: $("depositConfirm"), + depositError: $("depositError"), + depositSubmit: $("depositSubmitButton"), + depositBack: $("depositBackButton"), + haltGuidance: $("haltGuidance"), +}; + +function escapeHtml(value) { + const node = document.createElement("div"); + node.textContent = value == null ? "" : String(value); + return node.innerHTML + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatWon(value) { + const number = Number(value); + return Number.isFinite(number) ? `${won.format(number)}원` : "—"; +} + +function formatPercent(value, { sign = true } = {}) { + const number = Number(value); + if (!Number.isFinite(number)) return "—"; + const prefix = sign && number > 0 ? "+" : ""; + return `${prefix}${number.toFixed(2)}%`; +} + +function toneFor(value) { + const number = Number(value); + if (!Number.isFinite(number) || number === 0) return "muted"; + return number > 0 ? "positive" : "negative"; +} + +function parseDate(value) { + if (!value) return null; + const text = String(value); + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text) + ? `${text}T00:00:00+09:00` + : text; + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatDate(value, formatter = dateOnly) { + const parsed = parseDate(value); + return parsed ? formatter.format(parsed) : "—"; +} + +function localIsoDate(value = new Date()) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function calendarAgeDays(value) { + const parsed = parseDate(value); + if (!parsed) return null; + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const target = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()); + return Math.max(0, Math.floor((today - target) / 86_400_000)); +} + +async function fetchJson(url, { timeout = 15_000, options = {}, key = url } = {}) { + const previous = state.activeRequests.get(key); + if (previous) previous.abort(); + + const controller = new AbortController(); + state.activeRequests.set(key, controller); + const timer = window.setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + let payload; + try { + payload = await response.json(); + } catch { + payload = null; + } + if (!response.ok) { + const reason = payload && payload.error ? payload.error : `HTTP ${response.status}`; + throw new Error(reason); + } + return payload; + } finally { + window.clearTimeout(timer); + if (state.activeRequests.get(key) === controller) { + state.activeRequests.delete(key); + } + } +} + +function setSyncState(kind, label) { + elements.syncMark.dataset.state = kind; + elements.syncStatus.textContent = label; +} + +function updateSyncIndicator() { + if (state.coreStatus === "loading") { + setSyncState("loading", "장부 데이터 확인 중…"); + } else if (state.coreStatus === "error") { + setSyncState("error", "장부 연결 실패"); + } else if (state.runtimeStatus === "error") { + setSyncState("partial", "장부 정상 · 안전 상태 확인 불가"); + } else if (state.coreStatus === "partial") { + setSyncState("partial", "일부 장부 데이터 지연"); + } else { + setSyncState("ok", "장부 연결 정상"); + } + updateDepositAvailability(); +} + +function canRecordDeposit() { + const baskets = state.baskets; + const halt = state.runtime && state.runtime.trading_halt; + return ( + state.coreStatus === "ready" + && state.runtimeStatus === "ready" + && ["paper", "live"].includes(state.mode) + && Array.isArray(baskets) + && baskets.length > 0 + && baskets.every((basket) => state.flowStatus.get(basket.basket) === "ready") + && halt + && halt.halted === false + ); +} + +function updateDepositAvailability() { + const available = canRecordDeposit(); + elements.openDeposit.disabled = !available; + elements.depositAvailability.textContent = available + ? "적립금을 기록할 수 있습니다." + : "장부와 거래 안전 상태가 정상으로 확인된 뒤 적립금을 기록할 수 있습니다."; +} + +function markCoreSuccess(timestamp) { + const parsed = parseDate(timestamp) || new Date(); + state.lastCoreSuccess = parsed; + state.coreError = null; + elements.lastUpdate.dateTime = parsed.toISOString(); + elements.lastUpdate.textContent = `마지막 성공 ${dateTime.format(parsed)}`; +} + +function setMode(mode) { + const normalized = String(mode || "unknown").toLowerCase(); + state.mode = ["paper", "live"].includes(normalized) ? normalized : "unknown"; + elements.modeBadge.dataset.mode = state.mode; + if (state.mode === "paper") { + elements.modeBadge.textContent = "모의 운용 · 실제 주문 없음"; + } else if (state.mode === "live") { + elements.modeBadge.textContent = "실전 운용 · 실계좌 주문 가능"; + } else { + elements.modeBadge.textContent = "운용 모드 확인 불가"; + } + updateDepositCopy(); +} + +function sortedBaskets() { + return [...(state.baskets || [])].sort((a, b) => { + const primaryDelta = Number(Boolean(b.is_primary)) - Number(Boolean(a.is_primary)); + if (primaryDelta) return primaryDelta; + return String(a.basket).localeCompare(String(b.basket)); + }); +} + +function primaryBasket() { + return sortedBaskets().find((basket) => basket.is_primary) || null; +} + +function renderPortfolioSummary() { + const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot) || null; + const assetLabel = state.mode === "live" ? "현재 실전 자산" : "현재 모의 자산"; + if (!basket || !basket.snapshot) { + elements.portfolioSummary.innerHTML = [ + [assetLabel, "기록 없음"], + ["누적 원금", "기록 없음"], + ["원금 대비 손익", "기록 없음"], + ["현금 비중", "기록 없음"], + ].map(([label, value]) => `
${label}
${value}
`).join(""); + return; + } + + const totalValue = Number(basket.snapshot.total_value || 0); + const principal = Number(basket.principal || 0); + const cash = Number(basket.snapshot.cash || 0); + const profit = totalValue - principal; + const cashRatio = totalValue > 0 ? (cash / totalValue) * 100 : null; + elements.portfolioSummary.setAttribute( + "aria-label", + `${basket.display_name} 주력 포트폴리오 요약`, + ); + + const rows = [ + [assetLabel, formatWon(totalValue), ""], + ["누적 원금", formatWon(principal), ""], + ["원금 대비 손익", `${profit > 0 ? "+" : ""}${formatWon(profit)}`, toneFor(profit)], + ["현금 비중", cashRatio == null ? "—" : formatPercent(cashRatio, { sign: false }), ""], + ]; + elements.portfolioSummary.innerHTML = rows.map(([label, value, tone]) => ( + `
${label}
${escapeHtml(value)}
` + )).join(""); +} + +function positionLabel(position) { + const name = position.name ? `${position.name} · ` : ""; + return `${name}${position.symbol} ${won.format(Number(position.quantity || 0))}주`; +} + +function renderBasketTracks(data) { + const baskets = (data && data.baskets) || []; + state.baskets = baskets; + elements.basketTracks.setAttribute("aria-busy", "false"); + setMode(data && data.mode); + markCoreSuccess(data && data.timestamp); + + if (!baskets.length) { + elements.basketTracks.innerHTML = ` +
+ 활성화된 포트폴리오가 없습니다. + config/baskets.yaml에서 모의 운용 포트폴리오를 먼저 선택하세요. +
`; + renderPortfolioSummary(); + elements.portfolioAsOf.textContent = "기준일 없음"; + elements.portfolioAsOf.dateTime = ""; + return; + } + + elements.basketTracks.innerHTML = sortedBaskets().map((basket) => { + const snapshot = basket.snapshot; + const twr = snapshot ? Number(snapshot.cumulative_return) : null; + const profit = basket.profit_vs_principal == null ? null : Number(basket.profit_vs_principal); + const deployment = basket.deployment_ratio == null ? null : Number(basket.deployment_ratio) * 100; + const target = basket.design_fraction == null ? null : Number(basket.design_fraction) * 100; + const primary = Boolean(basket.is_primary); + const flowItems = state.flows.get(basket.basket) || []; + const flowState = state.flowStatus.get(basket.basket); + const latestFlow = flowItems[0]; + const holdings = (basket.positions || []).length + ? `
    ${basket.positions.map((position) => `
  • ${escapeHtml(positionLabel(position))}
  • `).join("")}
` + : '

아직 보유 종목이 없습니다.

'; + const allocationWidth = deployment == null ? 0 : Math.max(0, Math.min(100, deployment)); + const role = basket.purpose || (primary ? "월 적립 중심" : "장기 관찰용"); + const plan = basket.contribution_plan || {}; + const planCopy = primary && plan.enabled && Number(plan.amount) > 0 + ? `운용 기준 · 월 ${formatWon(plan.amount)} 적립 · 큰 비중 이탈 때만 리밸런싱` + : ""; + const allocationGap = deployment == null || target == null ? null : target - deployment; + const allocationNote = allocationGap != null && allocationGap > 1 + ? (primary + ? `목표보다 ${Math.round(allocationGap)}%p 낮음 · ETF 1주 단위라 다음 적립 때 조정될 수 있습니다.` + : `목표보다 ${Math.round(allocationGap)}%p 낮습니다. 관찰 기준에 따라 큰 이탈만 점검합니다.`) + : "목표 범위에 가깝게 운용 중입니다."; + + return ` +
+
+
+ ${primary ? "주력 포트폴리오" : "관찰 포트폴리오"} + ${escapeHtml(role)} +
+

${escapeHtml(basket.display_name)}

+ ${escapeHtml(basket.basket)} +
${snapshot ? won.format(Number(snapshot.total_value)) : "기록 없음"}${snapshot ? "" : ""}
+

누적 원금 ${formatWon(basket.principal)}${basket.deposits_total > 0 ? ` · 적립 ${formatWon(basket.deposits_total)}` : ""}

+

${snapshot ? `자산 기준 ${formatDate(snapshot.date)}` : "모의 운용을 실행하면 첫 기록이 만들어집니다."}

+ ${planCopy ? `

${escapeHtml(planCopy)}

` : ""} +
+
+
+
입출금 제외 수익 (TWR)
${twr == null ? "—" : formatPercent(twr)}
+
원금 대비 손익
${profit == null ? "—" : `${profit > 0 ? "+" : ""}${formatWon(profit)}`}
+
고점 대비 최대 하락 (MDD)
${snapshot ? formatPercent(-Math.abs(Number(snapshot.mdd || 0)), { sign: false }) : "—"}
+
현금
${snapshot ? formatWon(snapshot.cash) : "—"}
+
+
+
투자 배치율${deployment == null ? "—" : `${Math.round(deployment)}%`} / 목표 ${target == null ? "—" : `${Math.round(target)}%`}
+
+

${escapeHtml(allocationNote)}

+
+ ${holdings} + ${latestFlow ? `

최근 적립 · ${formatDate(latestFlow.occurred_at, dateShort)} · +${formatWon(latestFlow.amount)}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}

` : ""} + ${flowState === "error" ? '

적립 기록을 확인할 수 없습니다. 새 기록을 추가하지 말고 다시 확인하세요.

' : ""} +
+
`; + }).join(""); + + const referenceBasket = primaryBasket() || sortedBaskets().find((basket) => basket.snapshot); + const referenceDate = referenceBasket && referenceBasket.snapshot && referenceBasket.snapshot.date; + elements.portfolioAsOf.dateTime = referenceDate || ""; + elements.portfolioAsOf.textContent = referenceDate + ? `주력 기준 ${formatDate(referenceDate)}` + : "첫 기록 대기 중"; + renderPortfolioSummary(); +} + +async function refreshFlows() { + const baskets = state.baskets || []; + const tasks = baskets.map(async (basket) => { + state.flowStatus.set(basket.basket, "loading"); + try { + const data = await fetchJson(`/api/cash_flows?basket=${encodeURIComponent(basket.basket)}`, { + timeout: 10_000, + key: `flows:${basket.basket}`, + }); + state.flows.set(basket.basket, (data && data.flows) || []); + state.flowStatus.set(basket.basket, "ready"); + return true; + } catch { + state.flowStatus.set(basket.basket, "error"); + return false; + } + }); + const results = await Promise.all(tasks); + state.flowError = results.some((ok) => !ok); + if (state.baskets) renderBasketTracks({ baskets: state.baskets, mode: state.mode, timestamp: state.lastCoreSuccess }); + renderDecision(); + return !state.flowError; +} + +function ensureChartAccountOptions() { + const wanted = sortedBaskets().map((basket) => ({ + value: basket.account_key, + label: basket.display_name, + })); + wanted.push({ value: "", label: "레거시 기본 계정" }); + const signature = JSON.stringify(wanted); + if (elements.chartAccount.dataset.signature === signature) return; + + const previous = state.chartAccount; + elements.chartAccount.dataset.signature = signature; + elements.chartAccount.innerHTML = wanted.map((option) => ( + `` + )).join(""); + const exists = previous !== null && wanted.some((option) => option.value === previous); + elements.chartAccount.value = exists ? previous : (wanted[0] ? wanted[0].value : ""); + state.chartAccount = elements.chartAccount.value; +} + +function syncChartQuery() { + const params = new URLSearchParams(window.location.search); + params.set("days", String(state.chartDays)); + if (state.chartAccount) params.set("account", state.chartAccount); + else params.delete("account"); + const queryString = params.toString(); + history.replaceState(null, "", `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`); +} + +function renderChartTable(snapshots) { + elements.chartRows.innerHTML = snapshots.map((snapshot) => ` + + + ${escapeHtml(formatWon(snapshot.total_value))} + ${escapeHtml(formatPercent(snapshot.cumulative_return))} + `).join(""); +} + +function renderChartMaturity() { + const basket = (state.baskets || []).find((item) => item.account_key === state.chartAccount); + if (!basket) { + elements.chartMaturity.textContent = "레거시 기본 계정은 모의 운용 검증 트랙과 분리됩니다."; + return; + } + const evaluation = (state.evaluations || []).find((item) => item.basket === basket.basket); + if (!evaluation) { + elements.chartMaturity.textContent = "운용 기록 성숙도를 확인하고 있습니다."; + return; + } + const days = Number(evaluation.progress_days || 0); + const minimum = Math.max(1, Number(evaluation.min_trading_days || 60)); + if (days < 20) { + elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 추세를 판단하기 전입니다.`; + } else if (days < minimum) { + elements.chartMaturity.textContent = `운용 ${days}영업일차 · ${minimum}영업일까지 기록 무결성을 우선 확인합니다.`; + } else { + elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 기록 검토가 가능한 구간입니다.`; + } +} + +function drawChart(rows) { + if (!rows.length || elements.chartWrap.hidden) return; + const canvas = $("chartEquity"); + const width = Math.max(280, elements.chartWrap.clientWidth); + const height = Math.max(280, elements.chartWrap.clientHeight); + const ratio = Math.min(2, window.devicePixelRatio || 1); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const context = canvas.getContext("2d"); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, width, height); + + const padding = { top: 22, right: 18, bottom: 38, left: width < 480 ? 58 : 72 }; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + const values = rows.map((row) => Number(row.total_value || 0)); + let minimum = Math.min(...values); + let maximum = Math.max(...values); + const spread = Math.max(1, maximum - minimum); + minimum -= spread * 0.08; + maximum += spread * 0.08; + + const x = (index) => padding.left + (plotWidth * index) / Math.max(1, rows.length - 1); + const y = (value) => padding.top + ((maximum - value) / (maximum - minimum)) * plotHeight; + + context.font = '10px "IBM Plex Mono", monospace'; + context.fillStyle = "#5d6966"; + context.strokeStyle = "#e5e1d8"; + context.lineWidth = 1; + context.textAlign = "right"; + context.textBaseline = "middle"; + for (let index = 0; index <= 4; index += 1) { + const value = maximum - ((maximum - minimum) * index) / 4; + const lineY = padding.top + (plotHeight * index) / 4; + context.beginPath(); + context.moveTo(padding.left, lineY); + context.lineTo(width - padding.right, lineY); + context.stroke(); + context.fillText(`${compactWon.format(value)}원`, padding.left - 9, lineY); + } + + context.strokeStyle = "#18201f"; + context.lineWidth = 2; + context.beginPath(); + rows.forEach((row, index) => { + const pointX = x(index); + const pointY = y(values[index]); + if (index === 0) context.moveTo(pointX, pointY); + else context.lineTo(pointX, pointY); + }); + context.stroke(); + + const tickStep = Math.max(1, Math.ceil((rows.length - 1) / 5)); + context.textAlign = "center"; + context.textBaseline = "top"; + rows.forEach((row, index) => { + if (index % tickStep !== 0 && index !== rows.length - 1) return; + context.fillStyle = "#5d6966"; + context.fillText(formatDate(row.date, dateShort), x(index), height - padding.bottom + 12); + }); + + if (rows.length <= 14) { + rows.forEach((row, index) => { + context.beginPath(); + context.arc(x(index), y(values[index]), 3, 0, Math.PI * 2); + context.fillStyle = "#b84a2a"; + context.fill(); + context.strokeStyle = "#fffdf8"; + context.lineWidth = 1; + context.stroke(); + }); + } +} + +function updateChart(snapshots) { + const rows = Array.isArray(snapshots) ? snapshots : []; + state.chartRows = rows; + renderChartTable(rows); + renderChartMaturity(); + + if (!rows.length) { + elements.chartWrap.hidden = true; + elements.chartEmpty.hidden = false; + elements.chartSummary.textContent = "아직 선택한 기간의 자산 기록이 없습니다."; + return; + } + + const first = rows[0]; + const last = rows.at(-1); + const change = Number(last.total_value || 0) - Number(first.total_value || 0); + elements.chartSummary.textContent = `${formatDate(first.date, dateShort)}부터 ${formatDate(last.date, dateShort)}까지 ${change >= 0 ? "+" : ""}${formatWon(change)} · 최근 TWR ${formatPercent(last.cumulative_return)}`; + elements.chartWrap.hidden = false; + elements.chartEmpty.hidden = true; + drawChart(rows); +} + +async function refreshChart() { + ensureChartAccountOptions(); + state.chartAccount = elements.chartAccount.value; + syncChartQuery(); + try { + const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, { + timeout: 15_000, + key: "chart", + }); + updateChart((data && data.snapshots) || []); + } catch { + elements.chartSummary.textContent = "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요."; + elements.chartWrap.hidden = true; + elements.chartEmpty.hidden = false; + elements.chartEmpty.innerHTML = "성과 데이터를 확인할 수 없습니다.기존 장부는 변경되지 않았습니다. 잠시 후 다시 확인하세요."; + } +} + +const verdictCopy = { + PASS_CANDIDATE: ["검토 준비", "positive"], + FAIL_REVIEW: ["재점검 필요", "negative"], + WAIT: ["관찰 중", "muted"], +}; + +function renderEvaluations(evaluations) { + const items = Array.isArray(evaluations) ? evaluations : []; + state.evaluations = items; + elements.basketEval.setAttribute("aria-busy", "false"); + if (!items.length) { + elements.basketEval.innerHTML = '

검토 중인 포트폴리오가 없습니다.

'; + renderDecision(); + return; + } + + const basketByName = new Map((state.baskets || []).map((basket) => [basket.basket, basket])); + const ordered = [...items].sort((left, right) => ( + Number(Boolean(basketByName.get(right.basket)?.is_primary)) + - Number(Boolean(basketByName.get(left.basket)?.is_primary)) + )); + elements.basketEval.innerHTML = `
    ${ordered.map((item) => { + const basket = basketByName.get(item.basket); + const displayName = basket?.display_name || item.basket; + const isPrimary = Boolean(basket?.is_primary); + const progressDays = Number(item.progress_days || 0); + const minimumDays = Math.max(1, Number(item.min_trading_days || 60)); + const progress = Math.min(100, Math.round((progressDays / minimumDays) * 100)); + const coverage = item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100); + const [copy, tone] = verdictCopy[item.verdict] || ["관찰 중", "muted"]; + const issues = (item.issues || []).slice(0, 3); + return `
  1. +
    ${escapeHtml(displayName)}${isPrimary ? '주력' : ""}${copy}
    +
    +
    ${progressDays}/${minimumDays} 영업일${coverage == null ? "기록 누락 확인 중" : (coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`)}
    + ${issues.length ? `
      ${issues.map((issue) => `
    • ${escapeHtml(issue)}
    • `).join("")}
    ` : ""} +
  2. `; + }).join("")}
`; + renderChartMaturity(); + renderDecision(); +} + +function trustCard(label, value, stateName = "info", support = "") { + return `
${escapeHtml(label)}${value}${support ? `${escapeHtml(support)}` : ""}
`; +} + +function marketCopy(regime) { + const value = String(regime || "").toLowerCase(); + if (value === "bullish") return ["안정", "ok", "신규 매수 허용 구간"]; + if (value === "bearish") return ["방어", "warning", "신규 매수 제한 가능"]; + if (value === "caution") return ["주의", "warning", "포지션 규모 축소 구간"]; + return ["확인 불가", "warning", "시장 상태 데이터 없음"]; +} + +function strategyCopy(strategy) { + const value = String(strategy || "").toLowerCase(); + if (value === "scoring") return "종합 점수형"; + if (value === "basket_rebalance") return "포트폴리오 리밸런싱"; + return strategy || "—"; +} + +function signalCopy(signal) { + const value = String(signal || "").toUpperCase(); + return ({ BUY: "매수", SELL: "매도", HOLD: "대기" })[value] || signal || "—"; +} + +function signalSourceCopy(source) { + const value = String(source || "").toLowerCase(); + return ({ pre_market: "장 시작 전", intraday: "장중", post_market: "장 마감 후" })[value] || source || "—"; +} + +function formatAgeMinutes(minutes) { + if (minutes == null) return "기록 없음"; + if (minutes < 60) return `${minutes}분 전`; + if (minutes < 1_440) return `${Math.round(minutes / 60)}시간 전`; + return `${Math.round(minutes / 1_440)}일 전`; +} + +function renderRuntime(runtime) { + state.runtime = runtime || null; + state.runtimeStatus = runtime && runtime.trading_halt ? "ready" : "error"; + updateSyncIndicator(); + elements.runtimeOps.setAttribute("aria-busy", "false"); + if (!runtime) { + elements.haltGuidance.hidden = true; + elements.runtimeOps.innerHTML = [ + trustCard("거래 안전 상태", "확인 불가", "warning", "거래 중지 상태를 읽지 못함"), + trustCard("시장 환경", "확인 불가", "warning", "최근 데이터 없음"), + trustCard("자동 운용", "확인 불가", "warning", "스케줄러 상태 없음"), + trustCard("증권사 연결", "확인 불가", "warning", "요청 통계 없음"), + trustCard("데이터 기준", "확인 불가", "warning", "잠시 후 다시 확인"), + ].join(""); + elements.runtimeMeta.textContent = "일부 운영 정보를 불러오지 못했습니다. 자산 장부는 변경되지 않았습니다."; + renderSignals(null, null); + renderWsGap(null); + renderDecision(); + return; + } + + const halt = runtime.trading_halt; + const haltKnown = Boolean(halt && typeof halt.halted === "boolean"); + const halted = Boolean(halt && halt.halted); + const [market, marketState, marketSupport] = marketCopy(runtime.market_regime && runtime.market_regime.regime); + const loop = runtime.loop_metrics; + const kis = runtime.kis_stats; + const updatedAt = runtime.runtime_file_updated_at; + const ageMinutes = updatedAt ? Math.max(0, Math.round((Date.now() - (parseDate(updatedAt)?.getTime() || Date.now())) / 60_000)) : null; + const freshnessState = ageMinutes == null ? "warning" : (ageMinutes > 30 ? "warning" : "ok"); + const runtimeIsFresh = ageMinutes != null && ageMinutes <= 720; + const loopElapsed = loop && loop.recent_avg_elapsed_s != null + ? `${Number(loop.recent_avg_elapsed_s).toFixed(1)}초` + : null; + const loopValue = loopElapsed + ? (runtimeIsFresh ? loopElapsed : "기록 오래됨") + : "기록 없음"; + const loopSupport = loopElapsed + ? (runtimeIsFresh ? "최근 루프 평균" : `최근 루프 ${loopElapsed} · ${formatAgeMinutes(ageMinutes)}`) + : "스케줄러 상태 없음"; + const kisValue = kis && kis.minute_utilization_pct != null + ? `${Number(kis.minute_utilization_pct).toFixed(1)}%` + : null; + const kisCard = state.mode === "paper" + ? trustCard("증권사 연결", "모의 운용", "info", "실계좌 연결 대상 아님") + : trustCard("증권사 연결", kisValue ? (runtimeIsFresh ? kisValue : "기록 오래됨") : "기록 없음", kis && runtimeIsFresh ? "info" : "warning", kisValue ? (runtimeIsFresh ? "분당 요청 한도 사용률" : `최근 사용률 ${kisValue} · ${formatAgeMinutes(ageMinutes)}`) : "요청 통계 없음"); + + elements.runtimeOps.innerHTML = + trustCard("거래 안전 상태", !haltKnown ? "확인 불가" : (halted ? "거래 중지" : "운용 가능"), !haltKnown ? "warning" : (halted ? "error" : "ok"), !haltKnown ? "거래 중지 상태를 읽지 못함" : (halted ? (halt.reason || "운영자 확인 필요") : "거래 중지 없음")) + + trustCard("시장 환경", market, marketState, marketSupport) + + trustCard("자동 운용", loopValue, loop && runtimeIsFresh ? "ok" : "warning", loopSupport) + + kisCard + + trustCard("데이터 기준", formatAgeMinutes(ageMinutes), freshnessState, updatedAt ? formatDate(updatedAt, dateTime) : "스케줄러 데이터 없음"); + + const meta = []; + if (runtime.strategy) meta.push(`운용 전략 ${strategyCopy(runtime.strategy)}`); + if (updatedAt) meta.push(`스케줄러 마지막 기록 ${formatDate(updatedAt, dateTime)}`); + elements.runtimeMeta.textContent = meta.join(" · "); + elements.haltGuidance.hidden = !halted; + if (halted) { + $("haltGuidanceReason").textContent = halt.reason || "신규 매수는 명시적으로 해제하기 전까지 차단됩니다."; + } + renderSignals(runtime.signals_today, runtime.signals_date); + renderWsGap(runtime); + renderDecision(); +} + +function renderSignals(signals, signalsDate) { + const table = $("signalsTableWrap"); + const empty = $("signalEmpty"); + const error = $("signalError"); + const count = $("signalCount"); + if (signals == null) { + table.hidden = true; + empty.hidden = true; + error.hidden = false; + count.textContent = "확인 불가"; + return; + } + const isToday = !signalsDate || signalsDate === localIsoDate(); + const rows = isToday && Array.isArray(signals) ? signals : []; + count.textContent = `${won.format(rows.length)}건`; + table.hidden = !rows.length; + empty.hidden = Boolean(rows.length); + error.hidden = true; + empty.textContent = isToday + ? "오늘 기록된 신호가 없습니다. 이상 상태가 아닙니다." + : `오늘 기록된 신호가 없습니다. 마지막 신호 기록은 ${formatDate(signalsDate)}입니다.`; + $("signalRows").innerHTML = rows.map((signal) => ` + + + ${escapeHtml(signal.symbol || "—")} + ${escapeHtml(signalCopy(signal.signal))} + ${Number.isFinite(Number(signal.score)) ? Number(signal.score).toFixed(2) : "—"} + ${escapeHtml(signalSourceCopy(signal.source))} + `).join(""); +} + +function renderWsGap(runtime) { + const gap = runtime && runtime.ws_gap; + const summary = $("wsGapSummary"); + const table = $("wsGapTableWrap"); + const empty = $("wsGapEmpty"); + const unavailable = $("wsGapNA"); + if (!gap || !gap.available) { + summary.innerHTML = trustCard("웹소켓", "정보 없음", "warning", "스케줄러 기록 대기"); + table.hidden = true; + empty.hidden = true; + unavailable.hidden = false; + return; + } + + const gaps = gap.recent_gaps || []; + unavailable.hidden = true; + summary.innerHTML = + trustCard("웹소켓 상태", gap.is_connected ? "연결됨" : "연결 끊김", gap.is_connected ? "ok" : "error") + + trustCard("최근 공백", `${won.format(Number(gap.total_gap_count || 0))}건`, gap.total_gap_count > 0 ? "warning" : "ok"); + table.hidden = !gaps.length; + empty.hidden = Boolean(gaps.length); + $("wsGapRows").innerHTML = [...gaps].reverse().map((item) => ` + + ${escapeHtml(formatDate(item.disconnect_at, dateTime))} + ${escapeHtml(formatDate(item.reconnect_at, dateTime))} + ${escapeHtml(`${Number(item.gap_seconds || 0).toFixed(1)}초`)} + ${escapeHtml((item.affected_symbols || []).join(", ") || "—")} + ${item.rest_backfill_performed ? `${won.format(Number(item.rest_backfill_count || 0))}건` : "미수행"} + ${item.blackswan_cooldown_triggered ? '안전 정지' : (item.blackswan_checked ? "정상" : "—")} + `).join(""); +} + +function renderLegacy(portfolio) { + state.legacy = portfolio || null; + if (!portfolio) { + $("summary").innerHTML = trustCard("레거시 계정", "확인 불가", "warning"); + $("positionsWrap").hidden = true; + $("noPositions").hidden = false; + return; + } + $("summary").innerHTML = + trustCard("총 평가금", formatWon(portfolio.total_value), "info") + + trustCard("총 수익률", formatPercent(portfolio.total_return), Number(portfolio.total_return) >= 0 ? "ok" : "error") + + trustCard("현금", formatWon(portfolio.cash), "info") + + trustCard("실현 손익", formatWon(portfolio.realized_pnl), Number(portfolio.realized_pnl) >= 0 ? "ok" : "error") + + trustCard("최대 낙폭", formatPercent(-Math.abs(Number(portfolio.mdd || 0)), { sign: false }), "warning") + + trustCard("보유 종목", `${won.format(Number(portfolio.position_count || 0))}개`, "info"); + + const positions = portfolio.positions || []; + $("positionsWrap").hidden = !positions.length; + $("noPositions").hidden = Boolean(positions.length); + $("positions").innerHTML = positions.map((position) => ` + + ${escapeHtml(position.symbol || "—")} + ${won.format(Number(position.quantity || 0))} + ${escapeHtml(formatWon(position.avg_price))} + ${escapeHtml(formatWon(position.current_price))} + ${escapeHtml(formatWon(position.current_value))} + ${escapeHtml(formatPercent(position.pnl_rate))} + `).join(""); +} + +function latestSnapshotDate() { + const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot); + return basket && basket.snapshot ? basket.snapshot.date : null; +} + +function currentMonthContributionState(basket) { + if (!basket || !basket.contribution_plan?.enabled) return "not-planned"; + if (state.flowStatus.get(basket.basket) !== "ready") return "unknown"; + const now = new Date(); + const recorded = (state.flows.get(basket.basket) || []).some((flow) => { + const when = parseDate(flow.occurred_at); + return when && when.getFullYear() === now.getFullYear() && when.getMonth() === now.getMonth(); + }); + return recorded ? "recorded" : "empty"; +} + +function runtimeAgeMinutes() { + const updatedAt = state.runtime && state.runtime.runtime_file_updated_at; + const parsed = parseDate(updatedAt); + return parsed ? Math.max(0, Math.round((Date.now() - parsed.getTime()) / 60_000)) : null; +} + +function setDecision({ title, description, meta = "", action = null, actionLabel = "확인하기" }) { + elements.decisionTitle.textContent = title; + elements.decisionDescription.textContent = description; + elements.decisionMeta.innerHTML = meta; + elements.decisionAction.hidden = !action; + elements.decisionAction.dataset.action = action || ""; + elements.decisionAction.textContent = actionLabel; +} + +function renderDecision() { + const baskets = state.baskets; + const halt = state.runtime && state.runtime.trading_halt; + const latest = latestSnapshotDate(); + const ageDays = calendarAgeDays(latest); + const issues = (state.evaluations || []).flatMap((item) => item.issues || []); + const primary = primaryBasket(); + const contributionState = currentMonthContributionState(primary); + const modeCopy = state.mode === "live" ? "실전 운용" : (state.mode === "paper" ? "모의 운용" : "모드 확인 불가"); + const meta = `${escapeHtml(modeCopy)}${latest ? ` · 최근 자산 기록 ${escapeHtml(formatDate(latest))}` : " · 자산 기록 없음"}`; + + if (halt && halt.halted) { + setDecision({ + title: "거래가 안전하게 중지되어 있습니다", + description: halt.reason || "체결 또는 장부 상태를 확인하기 전까지 신규 주문을 막고 있습니다.", + meta, + action: "operations", + actionLabel: "운용 상태 보기", + }); + return; + } + if (state.coreError) { + setDecision({ + title: "자산 데이터를 확인할 수 없습니다", + description: "이전 화면을 최신 데이터로 표시하지 않았습니다. 연결을 확인한 뒤 다시 시도하세요.", + meta: state.lastCoreSuccess ? `마지막 성공 ${escapeHtml(dateTime.format(state.lastCoreSuccess))}` : "아직 성공한 갱신이 없습니다.", + action: "retry", + actionLabel: "지금 다시 확인", + }); + return; + } + if (state.runtimeStatus === "loading") { + setDecision({ + title: "거래 안전 상태를 확인하고 있습니다", + description: "거래 중지 여부와 자동 운용 기록을 확인한 뒤 오늘의 판단을 표시합니다.", + meta, + }); + return; + } + if (state.runtimeStatus === "error") { + setDecision({ + title: "거래 안전 상태를 확인할 수 없습니다", + description: "거래 중지 여부가 확인되기 전에는 적립 기록이나 운용 판단을 진행하지 마세요.", + meta, + action: "retry", + actionLabel: "안전 상태 다시 확인", + }); + return; + } + if (baskets && !baskets.length) { + setDecision({ + title: "첫 모의 운용 포트폴리오를 연결하세요", + description: "안전 기본값을 유지한 채 포트폴리오를 활성화하고 첫 모의 운용 기록을 만들어야 합니다.", + meta: "설정 → 모의 운용 1회 → 첫 자산 기록 확인", + action: "portfolio", + actionLabel: "시작 순서 보기", + }); + return; + } + if (baskets && baskets.length && !latest) { + setDecision({ + title: "첫 모의 운용 기록을 기다리고 있습니다", + description: "모의 운용을 한 번 실행하면 원금, 자산, 운용 수익률을 분리해 볼 수 있습니다.", + meta, + action: "portfolio", + actionLabel: "포트폴리오 확인", + }); + return; + } + if (ageDays != null && ageDays > 4) { + setDecision({ + title: "자산 기록이 오래되었습니다", + description: `마지막 자산 기록이 ${ageDays}일 전입니다. 자동 운용이 중단됐을 수 있으니 상태를 확인하세요.`, + meta, + action: "operations", + actionLabel: "운용 상태 확인", + }); + return; + } + const runtimeAge = runtimeAgeMinutes(); + if (runtimeAge == null || runtimeAge > 720) { + setDecision({ + title: "자동 운용 기록이 오래되었습니다", + description: runtimeAge == null + ? "최근 스케줄러 기록이 없습니다. 오늘 사이클이 실행됐는지 먼저 확인하세요." + : `마지막 자동 운용 기록이 ${formatAgeMinutes(runtimeAge)}입니다. 적립보다 실행 상태를 먼저 확인하세요.`, + meta, + action: "operations", + actionLabel: "운용 기록 확인", + }); + return; + } + if (issues.length) { + setDecision({ + title: "확인할 운영 항목이 있습니다", + description: `${issues.length}개 항목을 검토해야 합니다. 실전 전환은 계속 잠긴 상태입니다.`, + meta, + action: "review", + actionLabel: "검토 항목 보기", + }); + return; + } + if (contributionState === "unknown") { + setDecision({ + title: "적립 기록을 확인할 수 없습니다", + description: "조회 상태가 확인되기 전에는 같은 적립금을 다시 기록하지 마세요.", + meta, + action: "retry", + actionLabel: "적립 기록 다시 확인", + }); + return; + } + if (contributionState === "empty") { + const plannedAmount = Number(primary.contribution_plan?.amount || 0); + setDecision({ + title: "이번 달 실제 입금 여부를 확인하세요", + description: `${plannedAmount > 0 ? `운용 기준은 월 ${formatWon(plannedAmount)}입니다. ` : ""}실제 입금 또는 모의 적립이 완료된 경우에만 장부에 기록하세요.`, + meta, + action: "deposit", + actionLabel: "적립금 기록", + }); + return; + } + setDecision({ + title: "오늘은 할 일이 없습니다", + description: "계획을 유지하며 기록을 더 쌓는 중입니다. 매일 시세를 확인하거나 전략을 바꿀 필요가 없습니다.", + meta, + }); +} + +function updateDepositCopy() { + const description = $("depositDescription"); + if (!description) return; + description.textContent = state.mode === "live" + ? "실제 계좌에 입금이 완료된 뒤 같은 금액을 장부에 기록하세요. 주문은 실행되지 않지만 실전 성과 계산에 반영됩니다." + : "모의 적립금은 수익이 아니므로 입출금 제외 수익률 계산에서 분리됩니다. 기록 전 포트폴리오와 금액을 다시 살펴보세요."; +} + +function resetDepositForm() { + state.depositConfirming = false; + state.depositRequestId = null; + elements.depositForm.reset(); + elements.depositFields.hidden = false; + elements.depositConfirm.hidden = true; + elements.depositBack.hidden = true; + elements.depositSubmit.textContent = "내용 확인"; + elements.depositSubmit.disabled = false; + elements.depositError.hidden = true; + elements.depositError.textContent = ""; + document.querySelectorAll("[data-amount]").forEach((button) => button.setAttribute("aria-pressed", "false")); +} + +function openDeposit() { + if (!canRecordDeposit()) { + showToast("장부와 거래 안전 상태를 먼저 다시 확인하세요.", "error"); + return; + } + resetDepositForm(); + const select = $("depBasket"); + const baskets = sortedBaskets(); + select.innerHTML = baskets.map((basket) => ``).join(""); + const primary = primaryBasket(); + if (primary) select.value = primary.basket; + updateDepositCopy(); + if (!elements.depositDialog.open) elements.depositDialog.showModal(); + window.setTimeout(() => select.focus(), 0); +} + +function closeDeposit() { + if (elements.depositDialog.open) elements.depositDialog.close(); +} + +function showDepositError(message, field = null) { + elements.depositError.textContent = message; + elements.depositError.hidden = false; + if (field) field.focus(); +} + +function depositValues() { + const basket = $("depBasket").value; + const amount = Number($("depAmount").value); + const note = $("depNote").value.trim(); + return { basket, amount, note }; +} + +function showDepositConfirmation(values) { + state.depositRequestId = window.crypto?.randomUUID + ? window.crypto.randomUUID() + : `deposit-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const selected = $("depBasket").selectedOptions[0]; + $("confirmBasket").textContent = selected ? selected.textContent : values.basket; + $("confirmAmount").textContent = formatWon(values.amount); + $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의 장부"; + elements.depositFields.hidden = true; + elements.depositConfirm.hidden = false; + elements.depositBack.hidden = false; + elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록"; + elements.depositError.hidden = true; + state.depositConfirming = true; + elements.depositBack.focus(); +} + +function showDepositFields() { + state.depositConfirming = false; + state.depositRequestId = null; + elements.depositFields.hidden = false; + elements.depositConfirm.hidden = true; + elements.depositBack.hidden = true; + elements.depositSubmit.textContent = "내용 확인"; + $("depBasket").focus(); +} + +async function submitDeposit(values) { + elements.depositSubmit.disabled = true; + elements.depositSubmit.textContent = "기록하는 중…"; + elements.depositError.hidden = true; + try { + const data = await fetchJson("/api/deposit", { + timeout: 15_000, + key: "deposit", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "quant-dashboard", + "Idempotency-Key": state.depositRequestId, + }, + body: JSON.stringify(values), + }, + }); + if (!data || !data.ok) throw new Error((data && data.error) || "기록에 실패했습니다."); + closeDeposit(); + showToast(`${state.mode === "live" ? "실전 입금" : "모의 적립금"} ${formatWon(data.amount)}을 기록했습니다.`); + await refreshCore(); + } catch (error) { + showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`); + elements.depositSubmit.disabled = false; + elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록"; + } +} + +let toastTimer = null; +function showToast(message, kind = "ok") { + const toast = $("toast"); + window.clearTimeout(toastTimer); + toast.textContent = message; + toast.dataset.kind = kind; + toast.classList.add("show"); + toastTimer = window.setTimeout(() => toast.classList.remove("show"), 5_000); +} + +async function refreshCore() { + if (document.visibilityState === "hidden") return; + state.coreStatus = "loading"; + updateSyncIndicator(); + const basketTask = fetchJson("/api/baskets", { timeout: 12_000, key: "baskets" }) + .then(async (data) => { + renderBasketTracks(data); + await Promise.allSettled([refreshFlows(), refreshChart()]); + return data; + }); + const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" }) + .then(renderLegacy); + + const results = await Promise.allSettled([basketTask, legacyTask]); + const basketResult = results[0]; + if (basketResult.status === "rejected") { + state.coreError = basketResult.reason || new Error("바스켓 조회 실패"); + state.coreStatus = "error"; + elements.basketTracks.setAttribute("aria-busy", "false"); + elements.basketTracks.innerHTML = '
포트폴리오를 불러오지 못했습니다. 이전 데이터를 최신으로 표시하지 않았습니다.
'; + } else if (results.some((result) => result.status === "rejected") || state.flowError) { + state.coreStatus = "partial"; + } else { + state.coreStatus = "ready"; + } + updateSyncIndicator(); + renderDecision(); +} + +async function refreshSlow() { + if (document.visibilityState === "hidden") return; + if (!state.runtime) { + state.runtimeStatus = "loading"; + updateSyncIndicator(); + } + const evalTask = fetchJson("/api/basket_evaluation", { timeout: 30_000, key: "evaluation" }) + .then((data) => renderEvaluations((data && data.evaluations) || [])) + .catch(() => { + elements.basketEval.setAttribute("aria-busy", "false"); + elements.basketEval.innerHTML = '

모의 운용 검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.

'; + }); + const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" }) + .then(renderRuntime) + .catch(() => renderRuntime(null)); + await Promise.allSettled([evalTask, runtimeTask]); +} + +async function refreshAll() { + await Promise.allSettled([refreshCore(), refreshSlow()]); +} + +function wireEvents() { + elements.openDeposit.addEventListener("click", openDeposit); + $("closeDepositButton").addEventListener("click", closeDeposit); + $("depositCancelButton").addEventListener("click", closeDeposit); + elements.depositBack.addEventListener("click", showDepositFields); + $("retryButton").addEventListener("click", refreshAll); + + elements.decisionAction.addEventListener("click", () => { + const action = elements.decisionAction.dataset.action; + if (action === "deposit") openDeposit(); + else if (action === "retry") refreshAll(); + else if (action) document.getElementById(action)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + + elements.chartAccount.addEventListener("change", () => { + state.chartAccount = elements.chartAccount.value; + refreshChart(); + }); + + $("chartRange").addEventListener("click", (event) => { + const button = event.target.closest("button[data-days]"); + if (!button) return; + state.chartDays = Number(button.dataset.days); + document.querySelectorAll("#chartRange button").forEach((item) => item.setAttribute("aria-pressed", String(item === button))); + refreshChart(); + }); + + document.querySelectorAll("[data-amount]").forEach((button) => { + button.addEventListener("click", () => { + $("depAmount").value = button.dataset.amount; + document.querySelectorAll("[data-amount]").forEach((item) => item.setAttribute("aria-pressed", String(item === button))); + $("depAmount").focus(); + }); + }); + + elements.depositForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!canRecordDeposit()) { + showDepositError("장부와 거래 안전 상태를 최신으로 확인한 뒤 다시 시도하세요."); + updateDepositAvailability(); + return; + } + const values = depositValues(); + if (!state.depositConfirming) { + if (!values.basket) { + showDepositError("포트폴리오를 선택하세요.", $("depBasket")); + return; + } + if (!Number.isFinite(values.amount) || values.amount <= 0) { + showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount")); + return; + } + showDepositConfirmation(values); + return; + } + await submitDeposit(values); + }); + + elements.depositDialog.addEventListener("close", resetDepositForm); + elements.depositDialog.addEventListener("click", (event) => { + if (event.target !== elements.depositDialog) return; + const bounds = elements.depositDialog.getBoundingClientRect(); + const inside = event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom; + if (!inside) closeDeposit(); + }); + + window.addEventListener("online", refreshAll); + window.addEventListener("offline", () => { + state.coreError = new Error("오프라인"); + state.coreStatus = "error"; + updateSyncIndicator(); + renderDecision(); + }); + + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") refreshAll(); + }); +} + +async function boot() { + wireEvents(); + if ("ResizeObserver" in window) { + new ResizeObserver(() => drawChart(state.chartRows)).observe(elements.chartWrap); + } + document.querySelectorAll("#chartRange button").forEach((button) => { + button.setAttribute("aria-pressed", String(Number(button.dataset.days) === state.chartDays)); + }); + await refreshAll(); + window.setInterval(refreshCore, 30_000); + window.setInterval(refreshSlow, 60_000); +} + +boot(); diff --git a/monitoring/static/nungum-symbol.svg b/monitoring/static/nungum-symbol.svg new file mode 100644 index 00000000..a1fb743b --- /dev/null +++ b/monitoring/static/nungum-symbol.svg @@ -0,0 +1,6 @@ + + 눈금 + 서로 마주 보는 니은과 기역으로 기준과 기록의 분리를 나타낸 심볼 + + + diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html new file mode 100644 index 00000000..ba270063 --- /dev/null +++ b/monitoring/templates/dashboard.html @@ -0,0 +1,314 @@ + + + + + + + + 눈금 NUNGUM — 오래 투자하기 위한 기준과 기록 + + + + + + + + + + + + +
+
+
+

오늘의 운용 판단

+

현재 상태를 확인하고 있습니다

+

자산 기록과 안전 상태를 불러오는 중입니다.

+
+ +
+ +
+
+
현재 자산
+
+
+
+
누적 원금
+
+
+
+
원금 대비 손익
+
+
+
+
현금 비중
+
+
+
+
+ + + +
+
+
+

01 · PORTFOLIO

+

내 포트폴리오

+

주력 적립 트랙을 먼저 보여주고, 관찰용 트랙은 그다음에 배치합니다.

+
+ +
+
+
포트폴리오를 불러오는 중…
+
+
+ +
+
+
+

02 · PERFORMANCE

+

장기 성과

+

입출금과 운용 성과를 분리해 보고, 짧은 등락보다 긴 흐름을 확인합니다.

+
+
+ +
+
+
+
+

자산 흐름

+

성과 데이터를 불러오는 중입니다.

+

운용 기록 성숙도를 확인하고 있습니다.

+
+
+ + +
+ 조회 기간 + + + + +
+
+
+ +
+
+ +
+ +
평가금은 입금의 영향을 포함합니다. 실제 운용 성과는 입출금 제외 수익률을 함께 살펴보세요.
+
+ +
+ 차트 데이터를 표로 보기 +
+ + + + + + +
선택한 기간의 포트폴리오 자산과 누적 수익률
날짜평가금누적 수익률
+
+
+
+ + +
+
+ +
+
+
+

03 · OPERATIONS

+

운용 기록

+

자주 들여다보지 않아도 되도록 정상 여부와 예외만 간결하게 표시합니다.

+
+ +
+ +
+

+ + + +
+
+ + + 시스템이 처리한 오늘의 기록 + 매매 신호와 자동 운용 이력 + + 0건 + +
+
+ + + + +
오늘 생성된 자동 운용 신호
시각종목신호점수출처
+
+ + +
+
+ +
+ + + 고급 진단 + KIS 요청, 웹소켓, 레거시 계정 + + +
+
+ + + + +

레거시 기본 계정

+
+
+ + + + +
레거시 기본 계정의 보유 종목
종목수량평균가현재가평가액수익률
+
+ +
+
+
+
+ +
+ +

눈금 NUNGUM
오래 투자하기 위한 기준과 기록.

+ +
+
+ + +
+
+
+

CASH FLOW

+

적립금 기록

+
+ +
+

입금은 수익이 아니므로 입출금 제외 수익률 계산에서 분리됩니다. 기록 전 포트폴리오, 금액, 현재 모드를 다시 살펴보세요.

+ +
+
+ + +
+
+ +
+ 실제 입금 또는 모의 적립금과 같은 금액을 입력하세요. +
+ 빠른 금액 선택 + + + +
+
+
+ + +
+
+ + + + +
+ + + +
+
+
+ +
+ + diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index ee78eec0..2b9c48e4 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -1,12 +1,17 @@ """ -실시간 웹 대시보드 -- 콘솔 대시보드(monitoring/dashboard.py)를 확장한 웹 UI -- 포트폴리오 요약·포지션·스냅샷 추이를 실시간(폴링)으로 표시 +눈금(NUNGUM) 웹 대시보드. + +대시보드는 장부와 런타임 상태를 읽어 사용자가 오늘 해야 할 일, 장기 성과, +실전 전환 준비도를 한 화면에서 이해하도록 돕는다. 웹에서 가능한 쓰기는 +적립금 기록뿐이며 매매와 설정 변경은 의도적으로 제공하지 않는다. """ from __future__ import annotations from datetime import datetime +import ipaddress +from pathlib import Path +import re from typing import Optional try: @@ -16,14 +21,17 @@ from loguru import logger from config.config_loader import Config -from monitoring.dashboard import Dashboard from database.repositories import get_portfolio_snapshots +from monitoring.dashboard import Dashboard -# 기본 바인드 주소·포트 (settings.yaml dashboard 섹션으로 오버라이드 가능) DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8080 +_DASHBOARD_DIR = Path(__file__).resolve().parent +_TEMPLATE_PATH = _DASHBOARD_DIR / "templates" / "dashboard.html" +_STATIC_PATH = _DASHBOARD_DIR / "static" + def _require_aiohttp_web(): if web is None: @@ -31,33 +39,37 @@ def _require_aiohttp_web(): return web -def _serialize_snapshots(df): - """DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환. +def _active_ledger_mode(config=None) -> str: + """현재 설정의 장부 모드를 paper/live 두 값으로 정규화한다.""" + cfg = config or Config.get() + return "live" if str(cfg.trading.get("mode", "paper")).lower() == "live" else "paper" + - 날짜형은 컬럼을 특정하지 않고 전부 문자열화한다 — 'date'만 처리하던 시절 - created_at 컬럼 추가(일간 수익률 경계용)로 pd.Timestamp가 그대로 새어나가 - /api/snapshots가 매 폴링 500이 나고 수익률 차트가 조용히 죽었다(빈 DF만 - 쓰는 테스트는 통과해서 못 잡던 회귀). - """ +def _serialize_snapshots(df): + """DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환한다.""" if df.empty: return [] out = [] for _, row in df.iterrows(): - d = row.to_dict() - for k, v in d.items(): - if hasattr(v, "strftime"): # date/datetime/pd.Timestamp - d[k] = v.strftime("%Y-%m-%d %H:%M:%S") if k != "date" else v.strftime("%Y-%m-%d") - elif hasattr(v, "item"): # numpy 타입 → Python 네이티브 - d[k] = v.item() - out.append(d) + item = row.to_dict() + for key, value in item.items(): + if hasattr(value, "strftime"): + item[key] = ( + value.strftime("%Y-%m-%d") + if key == "date" + else value.strftime("%Y-%m-%d %H:%M:%S") + ) + elif hasattr(value, "item"): + item[key] = value.item() + out.append(item) return out -_DASH = None # 폴링(10초)마다 Dashboard/PortfolioManager를 새로 만들면 초기화 INFO가 스팸이 된다 +_DASH = None def get_portfolio_json(current_prices: Optional[dict] = None) -> dict: - """현재 포트폴리오 요약을 JSON 친화적 dict로 반환""" + """레거시 기본 계정의 현재 포트폴리오 요약을 반환한다.""" global _DASH config = Config.get() if _DASH is None: @@ -66,6 +78,7 @@ def get_portfolio_json(current_prices: Optional[dict] = None) -> dict: summary = dash.portfolio_manager.get_portfolio_summary(current_prices or {}) return { "timestamp": datetime.now().isoformat(), + "mode": _active_ledger_mode(config), "initial_capital": dash.initial_capital, "total_value": summary["total_value"], "cash": summary["cash"], @@ -81,26 +94,30 @@ def get_portfolio_json(current_prices: Optional[dict] = None) -> dict: def get_snapshots_json(days: int = 30, account_key: Optional[str] = None) -> dict: - """최근 N일 스냅샷을 JSON으로 반환""" - df = get_portfolio_snapshots(days=days, account_key=account_key) - return {"snapshots": _serialize_snapshots(df), "days": days} + """최근 N일 스냅샷을 활성 장부 모드에서 반환한다.""" + config = Config.get() + ledger_mode = _active_ledger_mode(config) + df = get_portfolio_snapshots( + days=days, + account_key=account_key, + mode=ledger_mode, + ) + return { + "snapshots": _serialize_snapshots(df), + "days": days, + "mode": ledger_mode, + } def get_baskets_json() -> dict: - """enabled 바스켓별 '내 돈' 요약 — 최신 스냅샷·원금(입금 포함)·배치율·보유 (DB 전용). - - 대시보드는 10초 폴링이므로 네트워크 조회를 섞지 않는다 — 평가금·수익률은 - 일일 사이클이 저장한 최신 스냅샷 값(TWR 반영), 보유는 DB 포지션(평균단가 기준). - 적립식 계정(kr_pocket)의 핵심 질문 "내가 넣은 돈 대비 얼마"에 답하는 화면 데이터다. - """ + """활성 바스켓별 원금·평가금·배치율·보유 현황을 DB에서만 읽는다.""" + from core.basket_deploy import effective_stock_fraction from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id - from database.repositories import ( - get_all_positions, - get_cash_flow_total, - ) from database.models import PortfolioSnapshot, get_session + from database.repositories import get_all_positions, get_cash_flow_total config = Config.get() + ledger_mode = _active_ledger_mode(config) baskets_cfg = BasketRebalancer._load_baskets_config() global_capital = (config.risk_params.get("position_sizing") or {}).get( "initial_capital", 10_000_000 @@ -108,80 +125,123 @@ def get_baskets_json() -> dict: out = [] for name in BasketRebalancer.get_enabled_baskets(): - cfg = baskets_cfg.get(name) or {} - key = rebalance_live_strategy_id(name) - initial = float(cfg.get("initial_capital") or global_capital) - deposits = float(get_cash_flow_total(account_key=key) or 0) - principal = initial + deposits + basket_config = baskets_cfg.get(name) or {} + account_key = rebalance_live_strategy_id(name) + initial_capital = float(basket_config.get("initial_capital") or global_capital) + deposits_total = float( + get_cash_flow_total(account_key=account_key, mode=ledger_mode) or 0 + ) + principal = initial_capital + deposits_total - # 최신 스냅샷 (mdd 포함해 직접 조회 — get_latest_snapshot_summary는 TWR용 최소 필드) session = get_session() try: - snap = ( + latest = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == key) + .filter( + PortfolioSnapshot.mode == ledger_mode, + PortfolioSnapshot.account_key == account_key, + ) .order_by(PortfolioSnapshot.date.desc()) .first() ) snapshot = None deployment_ratio = None - if snap is not None: - total = float(snap.total_value or 0) - cash = float(snap.cash or 0) - # 음수 클램프 — 헬스(run_health_check)와 동일 규칙(현금>총액 이상치 방어) - deployment_ratio = (max(0.0, (total - cash) / total)) if total > 0 else None + if latest is not None: + total_value = float(latest.total_value or 0) + cash = float(latest.cash or 0) + deployment_ratio = ( + max(0.0, (total_value - cash) / total_value) + if total_value > 0 + else None + ) snapshot = { - "date": str(snap.date)[:10], - "total_value": total, + "date": str(latest.date)[:10], + "total_value": total_value, "cash": cash, - "cumulative_return": float(snap.cumulative_return or 0), - "mdd": float(snap.mdd or 0), + "cumulative_return": float(latest.cumulative_return or 0), + "mdd": float(latest.mdd or 0), } finally: session.close() - # 설계 비중은 리밸런서·평가·헬스와 같은 단일 규칙을 쓴다 — 여기만 다르게 - # 계산하면 대시보드와 디스코드 카드가 서로 다른 '설계 %'를 보여준다. - from core.basket_deploy import effective_stock_fraction - design_fraction = effective_stock_fraction(cfg, config.risk_params) - + holding_names = basket_config.get("holding_names") or {} positions = [ { - "symbol": p.symbol, - "quantity": int(p.quantity or 0), - "avg_price": float(p.avg_price or 0), - "invested": float((p.quantity or 0) * (p.avg_price or 0)), + "symbol": position.symbol, + "name": holding_names.get(position.symbol), + "quantity": int(position.quantity or 0), + "avg_price": float(position.avg_price or 0), + "invested": float( + (position.quantity or 0) * (position.avg_price or 0) + ), } - for p in (get_all_positions(account_key=key) or []) - if (p.quantity or 0) > 0 + for position in ( + get_all_positions(account_key=account_key, mode=ledger_mode) or [] + ) + if (position.quantity or 0) > 0 ] - out.append({ - "basket": name, - "account_key": key, - "display_name": cfg.get("name") or name, - "initial_capital": initial, - "deposits_total": deposits, - "principal": principal, - "snapshot": snapshot, - "profit_vs_principal": ( - (snapshot["total_value"] - principal) if snapshot else None - ), - "deployment_ratio": deployment_ratio, - "design_fraction": design_fraction, - "positions": positions, - }) - return {"baskets": out, "timestamp": datetime.now().isoformat()} + is_primary = bool( + basket_config.get("primary", name == "kr_pocket") + ) + plan_config = basket_config.get("contribution_plan") or {} + contribution_plan = { + "enabled": bool(plan_config.get("enabled", False)), + "cadence": str(plan_config.get("cadence") or ""), + "amount": float(plan_config.get("amount") or 0), + } + out.append( + { + "basket": name, + "account_key": account_key, + "display_name": basket_config.get("name") or name, + "purpose": basket_config.get("purpose") + or ("월 적립 중심" if is_primary else "장기 관찰용"), + "is_primary": is_primary, + "contribution_plan": contribution_plan, + "initial_capital": initial_capital, + "deposits_total": deposits_total, + "principal": principal, + "snapshot": snapshot, + "profit_vs_principal": ( + snapshot["total_value"] - principal if snapshot else None + ), + "deployment_ratio": deployment_ratio, + "design_fraction": effective_stock_fraction( + basket_config, config.risk_params + ), + "positions": positions, + } + ) + + return { + "baskets": out, + "mode": ledger_mode, + "timestamp": datetime.now().isoformat(), + } + + +def _get_trading_halt_json() -> Optional[dict]: + """전역 HALT를 DB에서 매번 새로 읽어 JSON 형태로 반환한다.""" + try: + from database.repositories import get_trading_halt_state + + halt_state = get_trading_halt_state() + created_at = halt_state.get("created_at") + if hasattr(created_at, "isoformat"): + halt_state["created_at"] = created_at.isoformat() + return halt_state + except Exception as exc: + logger.debug("get_runtime_json trading_halt: {}", exc) + return None def get_runtime_json() -> dict: - """ - 시장 국면(실시간 조회) + 스케줄러가 기록한 신호·루프·블랙스완·KIS 통계(JSON 파일). - 각 항목 실패 시 해당 필드만 null — 프론트에서 '조회 불가' 표시. - """ + """시장·스케줄러 상태를 수집한다. HALT는 응답 직전 다시 확인한다.""" out: dict = { "timestamp": datetime.now().isoformat(), "market_regime": None, + "trading_halt": None, "signals_today": None, "signals_date": None, "strategy": None, @@ -193,631 +253,121 @@ def get_runtime_json() -> dict: "runtime_file_updated_at": None, } + out["trading_halt"] = _get_trading_halt_json() + try: - cfg = Config.get() - from core.market_regime import check_market_regime from core.data_collector import DataCollector + from core.market_regime import check_market_regime - mr = check_market_regime(cfg, DataCollector()) + config = Config.get() + regime = check_market_regime(config, DataCollector()) out["market_regime"] = { - "regime": mr.get("regime"), - "position_scale": mr.get("position_scale"), - "allow_buys": mr.get("allow_buys"), + "regime": regime.get("regime"), + "position_scale": regime.get("position_scale"), + "allow_buys": regime.get("allow_buys"), } - except Exception as e: - logger.debug("get_runtime_json market_regime: {}", e) + except Exception as exc: + logger.debug("get_runtime_json market_regime: {}", exc) try: from monitoring.dashboard_runtime_state import read_state - st = read_state() - out["runtime_file_updated_at"] = st.get("updated_at") - _raw_sigs = st.get("signals_today") - out["signals_today"] = _raw_sigs if isinstance(_raw_sigs, list) else [] - out["signals_date"] = st.get("signals_date") - out["strategy"] = st.get("strategy") - out["loop_metrics"] = st.get("loop_metrics") - out["blackswan"] = st.get("blackswan") - out["ws_gap"] = st.get("ws_gap") - if st.get("kis_stats") is not None: - out["kis_stats"] = st.get("kis_stats") + runtime_state = read_state() + out["runtime_file_updated_at"] = runtime_state.get("updated_at") + raw_signals = runtime_state.get("signals_today") + out["signals_today"] = raw_signals if isinstance(raw_signals, list) else [] + out["signals_date"] = runtime_state.get("signals_date") + out["strategy"] = runtime_state.get("strategy") + out["loop_metrics"] = runtime_state.get("loop_metrics") + out["blackswan"] = runtime_state.get("blackswan") + out["ws_gap"] = runtime_state.get("ws_gap") + if runtime_state.get("kis_stats") is not None: + out["kis_stats"] = runtime_state.get("kis_stats") out["kis_stats_source"] = "scheduler_file" - except Exception as e: - logger.debug("get_runtime_json read_state: {}", e) + except Exception as exc: + logger.debug("get_runtime_json read_state: {}", exc) out["signals_today"] = None - # KIS 통계 폴백(대시보드 프로세스에서 KISApi 신규 생성) 제거 — 레이트리미터 - # 상태가 인스턴스별이라 항상 0(아무것도 측정 안 함)에 폴링마다 초기화 로그만 - # 남겼다. 스케줄러 파일에 없으면 정직하게 '조회 불가'로 둔다. return out def _html_page() -> str: - """대시보드 단일 페이지 HTML — 2026-07 UI 개편(벤토 그리드·다크 글래스·Pretendard). - - 원칙: ① 내 돈(바스켓 트랙)이 첫 화면 ② 웹의 쓰기 권한은 '입금 기록' 하나 - (매매·설정 변경은 웹에 두지 않는다) ③ 폴링 경로에 네트워크 조회 없음(DB 전용 API). - """ - return """ - - - - - 퀀트 트레이더 - - - - - -
-
-
퀀트 트레이더paper 운영
- 갱신 - - -
- -
- -
-

내 자산

-
불러오는 중...
-
- -
-

수익률 추이

-
-
- -
-

승격 진행률 60영업일 트랙레코드

-
-
- -
-

운영 상태

-
-

-
- -
-

오늘 신호

-
-
- - - -
시각종목신호점수출처
-
- - -
-
- -
-
- 웹소켓 갭 · 레거시 기본 계정 -
-
- - - -
-
- - - -
종목수량평균가현재가평가액수익률
-
- -
-
-
- -
- - -
- -
- -
- - - -""" + """파일 기반 템플릿을 읽어 UI와 Python 데이터 계층을 분리한다.""" + return _TEMPLATE_PATH.read_text(encoding="utf-8") + + +def _api_error(label: str, exc: Exception, message: str) -> web.Response: + """내부 예외는 로그에만 남기고 브라우저에는 고정 문구만 반환한다.""" + logger.exception("{}: {}", label, exc) + return web.json_response({"error": message}, status=500) + + +async def _security_headers(request: web.Request, handler): + response = await handler(request) + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src https://fonts.gstatic.com; " + "img-src 'self' data:; connect-src 'self'; " + "object-src 'none'; base-uri 'none'; form-action 'self'; " + "frame-ancestors 'none'" + ) + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + return response async def handle_index(_request: web.Request) -> web.Response: - # aiohttp 3.13+: content_type에 charset을 섞으면 ValueError — 분리 인자로 전달. - # (기존 표기는 메인 페이지 '/'를 500으로 죽이는 운영 결함이었다 — API만 검증하고 - # 페이지 서빙은 검증하지 않아 가려져 있었다.) - return web.Response(text=_html_page(), content_type="text/html", charset="utf-8") + return web.Response( + text=_html_page(), + content_type="text/html", + charset="utf-8", + ) async def handle_api_portfolio(_request: web.Request) -> web.Response: - # live 모드에서는 KIS 잔고 조회(동기 네트워크)가 섞일 수 있다 — 스레드로 격리. import asyncio try: data = await asyncio.to_thread(get_portfolio_json) return web.json_response(data) - except Exception as e: - logger.exception("API /api/portfolio 오류: {}", e) - return web.json_response({"error": str(e)}, status=500) + except Exception as exc: + return _api_error( + "API /api/portfolio 오류", exc, "포트폴리오를 불러오지 못했습니다" + ) async def handle_api_baskets(_request: web.Request) -> web.Response: - """바스켓 트랙 '내 돈' 요약 — DB 전용(네트워크 조회 없음), 10초 폴링 안전.""" try: return web.json_response(get_baskets_json()) - except Exception as e: - logger.exception("API /api/baskets 오류: {}", e) - return web.json_response({"error": str(e)}, status=500) + except Exception as exc: + return _api_error( + "API /api/baskets 오류", exc, "포트폴리오를 불러오지 못했습니다" + ) async def handle_api_deposit(request: web.Request) -> web.Response: - """적립 입금 기록 (POST {basket, amount, note?}) — CLI와 동일한 단일 검증 경로. - - 웹에서 가능한 쓰기는 이것 하나다(기록·조회까지가 웹의 권한 — 매매·설정 변경은 - 웹에 두지 않는다). occurred_at은 서버 시각 고정이라 소급 조작이 불가능하고, - 금액 양수·바스켓 존재·TWR 체인 보호(마지막 스냅샷 이후) 검증은 공유 함수가 한다. - - CSRF 방어: 커스텀 헤더(X-Requested-With) 필수 — 루프백 바인딩이어도 브라우저 - 경유 cross-site 요청은 막지 못한다(악성 페이지가 text/plain fetch로 127.0.0.1에 - POST 가능, aiohttp request.json()은 Content-Type을 보지 않음). 커스텀 헤더는 - CORS preflight를 강제하는데 이 서버는 preflight에 응답하지 않으므로 외부 - 오리진에서는 실을 수 없다. 대시보드 프론트만 이 헤더를 보낸다. - """ + """적립금 기록. 커스텀 헤더로 cross-site 브라우저 요청을 차단한다.""" if request.headers.get("X-Requested-With") != "quant-dashboard": return web.json_response( - {"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"}, status=403, + {"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"}, + status=403, + ) + request_id = str(request.headers.get("Idempotency-Key") or "").strip() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{15,63}", request_id): + return web.json_response( + {"ok": False, "error": "유효한 입금 요청 키가 필요합니다"}, status=400 ) try: body = await request.json() except Exception: - return web.json_response({"ok": False, "error": "JSON 본문이 필요합니다"}, status=400) + return web.json_response( + {"ok": False, "error": "JSON 본문이 필요합니다"}, status=400 + ) + try: from tools.record_deposit import record_basket_deposit @@ -825,78 +375,103 @@ async def handle_api_deposit(request: web.Request) -> web.Response: str(body.get("basket") or ""), body.get("amount"), note=str(body.get("note") or ""), + request_id=request_id, ) if not result.get("ok"): return web.json_response(result, status=400) logger.info( "웹 입금 기록: {} +{:,.0f}원 (누적 입금 {:,.0f}원)", - result["account_key"], result["amount"], result["deposits_total"], + result["account_key"], + result["amount"], + result["deposits_total"], ) return web.json_response(result) - except Exception as e: - logger.exception("API /api/deposit 오류: {}", e) - return web.json_response({"ok": False, "error": str(e)}, status=500) + except Exception as exc: + logger.exception("API /api/deposit 오류: {}", exc) + return web.json_response( + {"ok": False, "error": "적립금을 기록하지 못했습니다"}, status=500 + ) async def handle_api_cash_flows(request: web.Request) -> web.Response: - """바스켓 입금 내역 (GET ?basket=) — 최근 12건.""" + """선택한 바스켓의 최근 적립금 기록을 활성 장부 모드에서 반환한다.""" try: from core.basket_rebalancer import rebalance_live_strategy_id from database.repositories import get_recent_cash_flows basket = request.query.get("basket") or "" if not basket: - return web.json_response({"error": "basket 파라미터 필요"}, status=400) - key = rebalance_live_strategy_id(basket) - return web.json_response({"basket": basket, "flows": get_recent_cash_flows(key)}) - except Exception as e: - logger.exception("API /api/cash_flows 오류: {}", e) - return web.json_response({"error": str(e)}, status=500) + return web.json_response( + {"error": "basket 파라미터 필요"}, status=400 + ) + ledger_mode = _active_ledger_mode() + account_key = rebalance_live_strategy_id(basket) + return web.json_response( + { + "basket": basket, + "mode": ledger_mode, + "flows": get_recent_cash_flows( + account_key, mode=ledger_mode + ), + } + ) + except Exception as exc: + return _api_error( + "API /api/cash_flows 오류", exc, "적립 기록을 불러오지 못했습니다" + ) + + +_RUNTIME_CACHE: dict = {"at": 0.0, "data": None} +_RUNTIME_TTL_SEC = 60.0 async def handle_api_runtime(_request: web.Request) -> web.Response: - # get_runtime_json은 시장 국면 실시간 조회(동기 네트워크, 수십 초 가능)를 포함한다 — - # 이벤트 루프에서 직접 부르면 그동안 '/'·내 자산·차트까지 전부 멈춘다(첫 로드 체감 저하). - # 스레드로 내려 다른 엔드포인트는 즉시 응답하게 한다. + """느린 외부 시장 조회를 이벤트 루프 밖에서 실행하고 60초간 캐시한다.""" import asyncio + import time as _time try: - data = await asyncio.to_thread(get_runtime_json) + now = _time.monotonic() + if ( + _RUNTIME_CACHE["data"] is not None + and now - _RUNTIME_CACHE["at"] < _RUNTIME_TTL_SEC + ): + cached = _RUNTIME_CACHE["data"] + else: + cached = await asyncio.to_thread(get_runtime_json) + _RUNTIME_CACHE["at"] = now + _RUNTIME_CACHE["data"] = cached + # HALT는 안전 판단의 현재값이므로 느린 시장 상태 캐시와 분리한다. + data = dict(cached) + data["trading_halt"] = await asyncio.to_thread(_get_trading_halt_json) return web.json_response(data) - except Exception as e: - logger.exception("API /api/runtime 오류: {}", e) - return web.json_response({"error": str(e)}, status=500) + except Exception as exc: + return _api_error( + "API /api/runtime 오류", exc, "안전 상태를 불러오지 못했습니다" + ) async def handle_api_snapshots(request: web.Request) -> web.Response: try: - days = int(request.query.get("days", 30)) - # 파라미터 '존재'와 '빈 값'을 구분한다: account_key=(빈)은 기본 계정('')의 - # 시계열을 뜻한다 — `or None`으로 강등하면 전 계정이 무필터로 섞여 - # 10M/30만 스케일이 한 차트에 뒤엉킨 톱니가 나온다. + requested_days = int(request.query.get("days", 30)) + days = max(1, min(3650, requested_days)) raw_key = request.query.get("account_key") account_key = raw_key if raw_key is not None else None - data = get_snapshots_json(days=days, account_key=account_key) - return web.json_response(data) - except Exception as e: - logger.exception("API /api/snapshots 오류: {}", e) - return web.json_response({"error": str(e)}, status=500) + return web.json_response( + get_snapshots_json(days=days, account_key=account_key) + ) + except Exception as exc: + return _api_error( + "API /api/snapshots 오류", exc, "성과 기록을 불러오지 못했습니다" + ) -# 평가 결과 캐시 (TTL 60초) — 수집기가 호출마다 TradingHours를 새로 만들어 -# 10초 폴링이면 INFO 로그 2줄×8,640회/일 스팸이 되고, holidays.yaml이 사라진 -# 환경에서는 pykrx 네트워크 갱신이 sync-in-async 핸들러를 매 폴링 블로킹할 수 -# 있다(잠재). 진행률은 하루 단위로 변하는 값이라 60초 캐시는 충분히 신선하다. _BASKET_EVAL_CACHE: dict = {"at": 0.0, "data": None} _BASKET_EVAL_TTL_SEC = 60.0 async def handle_api_basket_evaluation(_request: web.Request) -> web.Response: - """바스켓 paper 운영 평가(승격 진행률) — 게이트와 같은 수집기라 판정이 동일하다. - - read-only. include_benchmark=False로 네트워크(KS11 조회)를 피한다 — 대시보드는 - 10초 폴링이므로 외부 조회를 섞으면 안 된다. 결과는 60초 TTL 캐시. - """ + """바스켓 paper 운영 평가를 읽기 전용으로 반환한다.""" import asyncio import time as _time @@ -904,20 +479,23 @@ def _collect_all() -> dict: from core.basket_evaluation import collect_basket_paper_evaluation from core.basket_rebalancer import BasketRebalancer - out = [] + evaluations = [] for name in BasketRebalancer.get_enabled_baskets(): result, basket_name = collect_basket_paper_evaluation( - include_benchmark=False, basket_name=name, + include_benchmark=False, + basket_name=name, ) - out.append({ - "basket": basket_name, - "verdict": result.get("verdict"), - "progress_days": result.get("progress_days"), - "min_trading_days": result.get("min_trading_days"), - "snapshot_coverage": result.get("snapshot_coverage"), - "issues": result.get("issues", []), - }) - return {"evaluations": out} + evaluations.append( + { + "basket": basket_name, + "verdict": result.get("verdict"), + "progress_days": result.get("progress_days"), + "min_trading_days": result.get("min_trading_days"), + "snapshot_coverage": result.get("snapshot_coverage"), + "issues": result.get("issues", []), + } + ) + return {"evaluations": evaluations} try: now = _time.monotonic() @@ -927,20 +505,21 @@ def _collect_all() -> dict: ): return web.json_response(_BASKET_EVAL_CACHE["data"]) - # 수집기는 TradingHours 초기화·(잠재) pykrx 갱신 등 동기 작업 — 스레드로 내려 - # 캐시 미스 시에도 이벤트 루프가 다른 요청을 계속 처리하게 한다. payload = await asyncio.to_thread(_collect_all) _BASKET_EVAL_CACHE["at"] = now _BASKET_EVAL_CACHE["data"] = payload return web.json_response(payload) - except Exception as e: - logger.exception("API /api/basket_evaluation 오류: {}", e) - return web.json_response({"error": str(e)}, status=500) + except Exception as exc: + return _api_error( + "API /api/basket_evaluation 오류", + exc, + "모의 운용 검증 상태를 불러오지 못했습니다", + ) def create_app() -> web.Application: web_mod = _require_aiohttp_web() - app = web_mod.Application() + app = web_mod.Application(middlewares=[web_mod.middleware(_security_headers)]) app.router.add_get("/", handle_index) app.router.add_get("/api/portfolio", handle_api_portfolio) app.router.add_get("/api/runtime", handle_api_runtime) @@ -949,6 +528,12 @@ def create_app() -> web.Application: app.router.add_post("/api/deposit", handle_api_deposit) app.router.add_get("/api/cash_flows", handle_api_cash_flows) app.router.add_get("/api/basket_evaluation", handle_api_basket_evaluation) + app.router.add_static( + "/static/", + path=str(_STATIC_PATH), + name="dashboard_static", + show_index=False, + ) return app @@ -959,37 +544,69 @@ def _config_settings_dict(config) -> dict: return settings if isinstance(settings, dict) else {} -def resolve_dashboard_bind(host: Optional[str] = None, port: Optional[int] = None) -> tuple[str, int]: +def resolve_dashboard_bind( + host: Optional[str] = None, + port: Optional[int] = None, +) -> tuple[str, int]: """대시보드 바인드 주소를 해석한다. 기본은 로컬 루프백이다.""" try: - cfg = Config.get() - settings = _config_settings_dict(cfg) - dash_cfg = (settings.get("dashboard") or {}) if isinstance(settings, dict) else {} - host = host or str(dash_cfg.get("host") or "").strip() or DEFAULT_HOST - port = port if port is not None else dash_cfg.get("port") or DEFAULT_PORT + config = Config.get() + settings = _config_settings_dict(config) + dashboard_config = settings.get("dashboard") or {} + host = ( + host + or str(dashboard_config.get("host") or "").strip() + or DEFAULT_HOST + ) + port = ( + port + if port is not None + else dashboard_config.get("port") or DEFAULT_PORT + ) except Exception: host = host or DEFAULT_HOST port = port if port is not None else DEFAULT_PORT - return host, int(port) + normalized_host = str(host).strip().lower() + try: + is_loopback = ( + normalized_host == "localhost" + or ipaddress.ip_address(normalized_host).is_loopback + ) + except ValueError: + is_loopback = False + if not is_loopback: + raise ValueError( + "웹 대시보드는 인증을 제공하지 않으므로 loopback 주소에만 바인딩할 수 있습니다" + ) + return str(host), int(port) -def run_web_dashboard(host: Optional[str] = None, port: Optional[int] = None): - """웹 대시보드 서버 실행 (블로킹). host/port 미지정 시 config dashboard 섹션 또는 기본값 사용.""" +def run_web_dashboard( + host: Optional[str] = None, + port: Optional[int] = None, +): + """웹 대시보드 서버를 실행한다.""" host, port = resolve_dashboard_bind(host=host, port=port) web_mod = _require_aiohttp_web() - app = create_app() - logger.info("웹 대시보드 서버 시작: http://{}:{}/", host, port) - web_mod.run_app(app, host=host, port=port) + logger.info("눈금 웹 대시보드 시작: http://{}:{}/", host, port) + web_mod.run_app(create_app(), host=host, port=port) if __name__ == "__main__": import argparse - p = argparse.ArgumentParser(description="퀀트 트레이더 웹 대시보드") - p.add_argument("--host", default=None, help="바인드 주소 (기본: config 또는 127.0.0.1)") - p.add_argument("--port", type=int, default=None, help="포트 (기본: config 또는 8080)") - args = p.parse_args() + + parser = argparse.ArgumentParser(description="눈금 NUNGUM 웹 대시보드") + parser.add_argument( + "--host", default=None, help="바인드 주소 (기본: config 또는 127.0.0.1)" + ) + parser.add_argument( + "--port", type=int, default=None, help="포트 (기본: config 또는 8080)" + ) + args = parser.parse_args() + from database.models import init_database from monitoring.logger import setup_logger + setup_logger() init_database() run_web_dashboard(host=args.host, port=args.port) diff --git a/strategies/volatility_condition.py b/strategies/volatility_condition.py index b48e630d..3b7e8b7d 100644 --- a/strategies/volatility_condition.py +++ b/strategies/volatility_condition.py @@ -48,7 +48,11 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: close = result["close"].astype(float) ret = close.pct_change().dropna() # 롤링 표준편차 * sqrt(252) = 연율화 변동성 (% 단위로 하려면 *100) - vol = ret.rolling(lookback, min_periods=min(10, lookback)).std() * np.sqrt(252) * 100 + vol = ( + ret.rolling(lookback, min_periods=min(10, lookback)).std() + * np.sqrt(252) + * 100 + ).reindex(result.index) result["realized_vol_pct"] = vol # 스코어: 낮은 변동성 = 양수, 높은 변동성 = 음수 (정규화 -1~1 수준) @@ -56,8 +60,15 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: result["strategy_score"] = np.clip((mid - vol) / max(mid, 1), -2, 2) result["signal"] = self.HOLD - result.loc[vol <= low_max, "signal"] = self.BUY - result.loc[vol >= high_min, "signal"] = self.SELL + # pct_change().dropna() removes the first row, so rolling volatility has + # a shorter index unless it is explicitly aligned. Pandas 3 treats an + # unaligned boolean Series passed to .loc as labels and can raise + # ``TypeError: unhashable type: 'Series'``. Keep masks on result.index + # on every supported pandas version. + low_vol_mask = vol.le(low_max).reindex(result.index, fill_value=False) + high_vol_mask = vol.ge(high_min).reindex(result.index, fill_value=False) + result.loc[low_vol_mask, "signal"] = self.BUY + result.loc[high_vol_mask, "signal"] = self.SELL return result def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: diff --git a/tests/conftest.py b/tests/conftest.py index 93c47b79..c65fc522 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,3 +69,46 @@ def _isolate_sector_map_cache(tmp_path, monkeypatch): monkeypatch.setattr(_dc, "SECTOR_MAP_CACHE_PATH", tmp_path / "sector_map_cache.json") except Exception: pass + + +# --------------------------------------------------------------------------- +# 전역 거래 HALT 격리 (DB·캐시 격리와 같은 원리) +# +# HALT는 OperationEvent에 append-only로 쌓이고 '최신 이벤트가 이긴다'. 테스트 세션은 +# 임시 DB 하나를 공유하므로, 한 테스트가 켠 HALT는 정리하지 않으면 그 뒤 모든 테스트의 +# 신규 BUY를 막는다. 실제로 CI에서 live SELL 체결 미확인 테스트가 켠 HALT가 뒤따르는 +# 매수 테스트들로 번져 무더기 실패를 만들었다(2026-08-26). +# +# 개별 테스트의 규율(각자 clear 호출)에 맡기면 새 테스트가 추가될 때마다 다시 샌다 — +# 공용 인프라에서 강제한다(standing lesson: 격리는 공용 인프라에 둔다). +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _isolate_global_trading_halt(): + yield + try: + from database.models import OperationEvent, get_session + from database.repositories import TRADING_HALT_CLEARED, TRADING_HALT_SET + + session = get_session() + try: + leaked = ( + session.query(OperationEvent) + .filter( + OperationEvent.event_type.in_( + (TRADING_HALT_SET, TRADING_HALT_CLEARED) + ) + ) + .count() + ) + if leaked: + session.query(OperationEvent).filter( + OperationEvent.event_type.in_( + (TRADING_HALT_SET, TRADING_HALT_CLEARED) + ) + ).delete(synchronize_session=False) + session.commit() + finally: + session.close() + except Exception: + # 스키마 미생성 등으로 정리에 실패해도 테스트 결과를 바꾸지 않는다. + pass diff --git a/tests/test_backtest_cost_impact.py b/tests/test_backtest_cost_impact.py index 2f152b6a..1cd3a006 100644 --- a/tests/test_backtest_cost_impact.py +++ b/tests/test_backtest_cost_impact.py @@ -1,4 +1,5 @@ import pandas as pd +import pytest def test_cost_impact_marks_cost_flipped_result_as_fail(): @@ -68,6 +69,53 @@ class _Config: assert metrics["cost_impact_status"] == "fail" +def test_single_backtester_metrics_anchor_first_day_to_initial_capital(): + """첫날 즉시 발생한 손실/비용도 MDD와 일수익률에 포함한다.""" + from backtest.backtester import Backtester + + class _Config: + risk_params = {} + + equity = pd.DataFrame({ + "date": pd.to_datetime(["2026-01-02", "2026-01-05"]), + "value": [900.0, 900.0], + "cash": [900.0, 900.0], + "position_value": [0.0, 0.0], + }) + metrics = Backtester(_Config())._calculate_metrics( + {"equity_curve": equity, "trades": []}, + initial_capital=1_000.0, + ) + + assert metrics["total_return"] == -10.0 + assert metrics["max_drawdown"] == -10.0 + assert equity["daily_return"].iloc[0] == pytest.approx(-0.1) + assert metrics["sharpe_ratio"] < 0 + + +def test_portfolio_metrics_anchor_first_day_to_initial_capital(): + """포트폴리오 백테스터도 첫 관측일 이전 초기자본 기준점을 보존한다.""" + from backtest.portfolio_backtester import PortfolioBacktester + + class _Config: + risk_params = {} + + equity = pd.DataFrame({ + "date": pd.to_datetime(["2026-01-02", "2026-01-05"]), + "value": [900.0, 900.0], + "n_positions": [1, 1], + }) + metrics = PortfolioBacktester(_Config())._calculate_portfolio_metrics( + {"equity_curve": equity, "trades": []}, + initial_capital=1_000.0, + ) + + assert metrics["total_return"] == -10.0 + assert metrics["max_drawdown"] == -10.0 + assert equity["daily_return"].iloc[0] == pytest.approx(-0.1) + assert metrics["sharpe_ratio"] < 0 + + def test_text_report_contains_cost_before_after_section(tmp_path): from backtest.cost_impact import summarize_cost_impact from backtest.report_generator import ReportGenerator @@ -108,3 +156,86 @@ def test_text_report_contains_cost_before_after_section(tmp_path): assert "[ 비용 전/후 성과 비교 ]" in text assert "비용 차감 전 추정 수익률" in text assert "비용 드래그" in text + + +class _SymbolTaxBacktestConfig: + trading = {"market_regime_filter": False, "skip_earnings_days": 0} + risk_params = { + "position_sizing": {"max_risk_per_trade": 0.01}, + "stop_loss": {"type": "fixed", "fixed_rate": 0.03}, + "take_profit": {"fixed_rate": 0.08, "partial_exit": False}, + "trailing_stop": {"enabled": False}, + "diversification": { + "max_position_ratio": 0.20, + "max_investment_ratio": 0.70, + }, + "position_limits": {"min_holding_days": 0, "max_holding_days": 0}, + "backtest_regime_filter": {"enabled": False}, + "transaction_costs": { + "commission_rate": 0.0, + "tax_rate": 0.002, + "tax_exempt_symbols": ["069500", "357870"], + "holding_period_income_tax": { + "enabled": True, + "rate": 0.154, + "symbols": ["357870"], + }, + "slippage": 0.0, + "slippage_ticks": 0, + "dynamic_slippage": {"enabled": False}, + }, + } + + +class _PassthroughSignalStrategy: + @staticmethod + def analyze(df): + return df.copy() + + +def _run_symbol_tax_backtest(symbol): + from backtest.backtester import Backtester + + df = pd.DataFrame( + { + "open": [100.0, 101.0], + "high": [100.0, 101.0], + "low": [100.0, 101.0], + "close": [100.0, 101.0], + "volume": [1_000_000.0, 1_000_000.0], + "signal": ["BUY", "SELL"], + }, + index=pd.to_datetime(["2026-01-02", "2026-01-05"]), + ) + backtester = Backtester(_SymbolTaxBacktestConfig()) + backtester._get_strategy = lambda _name: _PassthroughSignalStrategy() + return backtester.run( + df, + strategy_name="passthrough", + initial_capital=1_000_000.0, + strict_lookahead=False, + symbol=symbol, + execution_model="legacy_same_close", + ) + + +def test_backtester_kr_equity_etf_sell_is_transaction_tax_exempt(): + result = _run_symbol_tax_backtest("069500") + + sell = next(t for t in result["trades"] if t["action"] == "SELL") + assert sell["tax"] == 0.0 + assert result["metrics"]["total_tax"] == 0.0 + + +def test_backtester_other_etf_sell_applies_holding_period_income_tax(): + result = _run_symbol_tax_backtest("357870") + + buy = next(t for t in result["trades"] if t["action"] == "BUY") + sell = next(t for t in result["trades"] if t["action"] == "SELL") + expected = round( + (sell["price"] - buy["price"]) * sell["quantity"] * 0.154, + 0, + ) + assert expected > 0 + assert sell["tax"] == pytest.approx(expected) + assert result["metrics"]["total_tax"] == pytest.approx(expected) diff --git a/tests/test_backtester_guards.py b/tests/test_backtester_guards.py index b2259f24..003fa404 100644 --- a/tests/test_backtester_guards.py +++ b/tests/test_backtester_guards.py @@ -103,6 +103,34 @@ def _make_guard_df(close, *, open_=None, signals=None, volume=1_000_000): return df +def test_strategy_signals_execute_one_bar_later_at_next_open(): + """종가로 확정된 전략 BUY/SELL은 신호일이 아닌 다음 거래일 시가에 체결된다.""" + from backtest.backtester import Backtester + + bt = Backtester(_BacktestGuardConfig(gap_enabled=False)) + df = _make_guard_df( + [100.0, 125.0, 126.0, 130.0], + open_=[90.0, 123.0, 126.0, 124.0], + signals=["BUY", "HOLD", "SELL", "HOLD"], + ) + + result = bt._simulate(df, initial_capital=100_000.0) + + assert result["execution_model"] == "next_open" + assert [trade["action"] for trade in result["trades"]] == ["BUY", "SELL"] + buy, sell = result["trades"] + assert buy["signal_date"] == df.index[0] + assert buy["date"] == df.index[1] + assert buy["price"] == pytest.approx(123.0) + assert sell["signal_date"] == df.index[2] + assert sell["date"] == df.index[3] + assert sell["price"] == pytest.approx(124.0) + assert not any(trade["date"] in {df.index[0], df.index[2]} for trade in result["trades"]) + + metrics = bt._calculate_metrics(result, initial_capital=100_000.0) + assert metrics["execution_model"] == "next_open" + + class TestLiquidityFilter: """백테스터 유동성 필터: 주문량이 일평균 거래량의 N%를 초과하면 축소.""" @@ -125,7 +153,9 @@ def test_low_volume_limits_quantity(self): df.iloc[30, df.columns.get_loc("signal")] = "BUY" df.iloc[55, df.columns.get_loc("signal")] = "SELL" - result = bt._simulate(df, initial_capital=100_000_000) + result = bt._simulate( + df, initial_capital=100_000_000, execution_model="legacy_same_close" + ) buy_trades = [t for t in result["trades"] if t["action"] == "BUY"] if buy_trades: @@ -150,7 +180,9 @@ def test_high_volume_no_limit(self): df["signal"] = "HOLD" df.iloc[30, df.columns.get_loc("signal")] = "BUY" - result = bt._simulate(df, initial_capital=100_000_000) + result = bt._simulate( + df, initial_capital=100_000_000, execution_model="legacy_same_close" + ) buy_trades = [t for t in result["trades"] if t["action"] == "BUY"] if buy_trades: @@ -192,7 +224,9 @@ def test_monthly_cap_blocks_excess_buys(self): df.iloc[buy_day, df.columns.get_loc("signal")] = "BUY" df.iloc[sell_day, df.columns.get_loc("signal")] = "SELL" - result = bt._simulate(df, initial_capital=100_000_000) + result = bt._simulate( + df, initial_capital=100_000_000, execution_model="legacy_same_close" + ) buy_trades = [t for t in result["trades"] if t["action"] == "BUY"] # 월 2회 제한이므로 3번째 매수는 차단되어야 함 @@ -251,7 +285,9 @@ def test_gap_up_blocks_new_buy(self): signals=["HOLD", "BUY", "HOLD"], ) - result = bt._simulate(df, initial_capital=100_000.0) + result = bt._simulate( + df, initial_capital=100_000.0, execution_model="legacy_same_close" + ) assert [t["action"] for t in result["trades"]] == [] assert result["gap_up_buy_blocks"] == 1 @@ -267,7 +303,9 @@ def test_earnings_window_blocks_new_buy(self): df["earnings_date"] = pd.NaT df.loc[df.index[1], "earnings_date"] = df.index[1] - result = bt._simulate(df, initial_capital=100_000.0) + result = bt._simulate( + df, initial_capital=100_000.0, execution_model="legacy_same_close" + ) assert [t["action"] for t in result["trades"]] == [] assert result["earnings_buy_blocks"] == 1 @@ -282,7 +320,9 @@ def test_gap_down_exit_preempts_close_stop_loss(self): signals=["BUY", "HOLD", "HOLD"], ) - result = bt._simulate(df, initial_capital=100_000.0) + result = bt._simulate( + df, initial_capital=100_000.0, execution_model="legacy_same_close" + ) actions = [t["action"] for t in result["trades"]] assert actions == ["BUY", "GAP_DOWN"] @@ -304,7 +344,9 @@ def test_blackswan_exit_blocks_cooldown_and_scales_recovery_buy(self): signals=["BUY", "HOLD", "BUY", "BUY"], ) - result = bt._simulate(df, initial_capital=100_000.0) + result = bt._simulate( + df, initial_capital=100_000.0, execution_model="legacy_same_close" + ) actions = [t["action"] for t in result["trades"]] assert actions == ["BUY", "BLACKSWAN", "BUY"] diff --git a/tests/test_backtester_strategies.py b/tests/test_backtester_strategies.py index cb14b731..e1652ee9 100644 --- a/tests/test_backtester_strategies.py +++ b/tests/test_backtester_strategies.py @@ -9,6 +9,7 @@ from strategies.mean_reversion import MeanReversionStrategy from strategies.scoring_strategy import ScoringStrategy from strategies.trend_following import TrendFollowingStrategy +from strategies.volatility_condition import VolatilityConditionStrategy from core.strategy_ensemble import StrategyEnsemble @@ -35,6 +36,7 @@ def test_strategy_analyze_contract_produces_signal_column(): ScoringStrategy, MeanReversionStrategy, TrendFollowingStrategy, + VolatilityConditionStrategy, StrategyEnsemble, ): analyzed = strategy_cls().analyze(df.copy()) @@ -50,3 +52,30 @@ def test_backtester_runs_for_all_strategies(): result = bt.run(df.copy(), strategy_name=strategy_name) assert result.get("metrics") assert "total_return" in result["metrics"] + + +def test_volatility_condition_aligns_boolean_masks_to_input_index(): + """pandas 2/3 모두에서 롤링 변동성 마스크는 원본 인덱스와 일치한다.""" + + class _VolatilityConfig: + strategies = { + "volatility_condition": { + "lookback_days": 20, + "low_vol_max_pct": 5.0, + "high_vol_min_pct": 30.0, + } + } + + low_vol_returns = np.full(50, 0.0001) + high_vol_returns = np.resize(np.array([0.05, -0.05]), 50) + prices = 100 * np.cumprod(1 + np.concatenate([low_vol_returns, high_vol_returns])) + index = pd.bdate_range("2025-01-02", periods=len(prices)) + df = pd.DataFrame({"close": prices}, index=index) + + analyzed = VolatilityConditionStrategy(_VolatilityConfig()).analyze(df) + + assert analyzed.index.equals(df.index) + assert analyzed["realized_vol_pct"].index.equals(df.index) + assert analyzed.iloc[0]["signal"] == "HOLD" + assert "BUY" in analyzed["signal"].values + assert "SELL" in analyzed["signal"].values diff --git a/tests/test_backtester_trailing_stop.py b/tests/test_backtester_trailing_stop.py index 5697fa66..a94cde1b 100644 --- a/tests/test_backtester_trailing_stop.py +++ b/tests/test_backtester_trailing_stop.py @@ -54,7 +54,9 @@ def test_backtester_trailing_stop_triggers(): index=dates, ) - result = bt._simulate(df, initial_capital=1_000_000) + result = bt._simulate( + df, initial_capital=1_000_000, execution_model="legacy_same_close" + ) actions = [t["action"] for t in result["trades"]] assert "TRAILING_STOP" in actions diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index 743bd2c0..2f31ff1c 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -161,6 +161,9 @@ def test_drift_trigger_skips(self, rebalancer): rebalancer.get_current_weights = MagicMock(return_value={ "005930": 0.39, "000660": 0.35, "035420": 0.25, }) + # 종목별 드리프트가 임계값 아래여도 집계 배치율이 밴드를 벗어나면 트리거된다 + # (현금 래칫 방지). 이 테스트는 종목별 트리거만 보므로 집계 격차는 0으로 둔다. + rebalancer._deployment_gap = MagicMock(return_value=0.0) should, reason = rebalancer.should_rebalance() assert should is False @@ -327,9 +330,17 @@ def test_diversified_hold_basket_is_low_turnover(self): baskets = self._load() assert "kr_diversified_hold" in baskets, "분산 보유 바스켓 누락" b = baskets["kr_diversified_hold"] - # 10종목 균등(각 10%) - assert len(b["holdings"]) == 10 - assert all(abs(float(w) - 0.10) < 1e-9 for w in b["holdings"].values()) + # 섹터 분산 균등 배분. 종목 수는 자본 규모에 따라 조정되므로(1주 가격이 슬롯 + # 금액을 넘는 종목은 편입 불가 — 2026-08-07에 000660 제외) 개수를 못 박지 않고 + # '균등 배분'이라는 설계 불변식만 검사한다. + holdings = b["holdings"] + assert len(holdings) >= 8, "섹터 분산이 무너질 만큼 종목이 줄었다" + equal_weight = 1.0 / len(holdings) + assert all( + abs(float(w) - equal_weight) < 0.001 for w in holdings.values() + ), f"균등 배분이 아님: {holdings}" + # 주식 노출은 명시적 정책이어야 한다(2026-08-07: 현금 완충 40%를 설계로 고정) + assert b["target_stock_weight"] == 0.60 # 저회전: 넓은 드리프트 임계 + 낮은 회전 상한 rb = b["rebalance"] assert rb["drift_threshold"] >= 0.08 @@ -377,12 +388,16 @@ def test_pocket_basket_small_capital_invariants(self): )} assert {"069500", "357870"} <= exempt - def test_observation_track_deployment_alarm_disabled(self): - """관찰용 강등(kr_diversified_hold): 종결된 자본 결정의 잔상인 배치율 미달이 - 상시 ATTENTION으로 남아 다른 바스켓 감시를 가리지 않도록 허용 오차 해제.""" - baskets = self._load() - b = baskets["kr_diversified_hold"] - assert float(b["monitoring"]["deployment_tolerance"]) >= 1.0 + def test_observation_track_deployment_alarm_is_enabled(self): + """배치율 감시는 켜져 있어야 한다(2026-08-26에 결정을 뒤집었다). + + 종전에는 tolerance 1.0으로 사실상 감시를 껐다 — '하이닉스 슬롯을 못 채워 생기는 + 미달은 조치 불가'였기 때문이다. 그 슬롯을 제거하고 목표 배치율을 60%로 명시한 + 지금은 미달이 곧 조치 대상(현금 누수)이고, 실제로 감시가 꺼져 있는 동안 + 배치율이 61.0% → 54.9%로 새는 것을 헬스가 3주간 전혀 잡지 못했다. + """ + b = self._load()["kr_diversified_hold"] + assert float(b["monitoring"]["deployment_tolerance"]) < 1.0 def test_all_basket_symbols_are_6digit_kr_codes(self): baskets = self._load() @@ -726,6 +741,7 @@ def test_warns_when_single_share_exceeds_target_amount(self, caplog): "rebalance": {"trigger": "drift", "drift_threshold": 0.08, "min_trade_amount": 200000, "max_turnover_ratio": 1.0}, } + rb.basket = rb.basket_cfg # 리스크 정책 조회원(재진입 차단 등) rb.rebalance_cfg = rb.basket_cfg["rebalance"] rb.account_key = "t" rb.execution_strategy = "t" @@ -733,6 +749,7 @@ def test_warns_when_single_share_exceeds_target_amount(self, caplog): get_portfolio_summary=lambda current_prices=None: {"total_value": 1_000_000}, ) rb._is_live = lambda: False + rb._ledger_mode = lambda: "paper" rb._stock_fraction = lambda: 1.0 rb.get_target_weights = lambda: {"000660": 0.5, "005930": 0.5} rb.get_current_weights = lambda prices=None: {} diff --git a/tests/test_basket_risk_policy.py b/tests/test_basket_risk_policy.py new file mode 100644 index 00000000..1f9207b2 --- /dev/null +++ b/tests/test_basket_risk_policy.py @@ -0,0 +1,300 @@ +"""바스켓 트랙별 리스크 정책 + 자기상관 자기거부 회귀 테스트. + +배경(2026-08-07 점검): 두 결함이 모의투자 트랙을 57거래일간 얼려 두었다. + 1) check_correlation_risk가 대상 종목 자신을 비교 대상에 포함해 corr(x,x)=1.0이 + 잡히고, 고정수량 어댑터는 scale<1.0을 하드 거부라 모든 추가매수가 영구 거부됐다. + 2) 전역 단타 손절(-3%)이 buy&hold 바스켓 포지션에 기록되기만 하고 일일 사이클은 + 평가하지 않아, 손절선을 뚫은 포지션이 그대로 방치됐다. +""" + +from types import SimpleNamespace + +import pytest + +from core.basket_risk import ( + RISK_EXIT_TAG, + basket_risk_config, + basket_risk_levels, + evaluate_basket_stops, + has_risk_policy, + reentry_cooldown_days, + symbols_in_reentry_cooldown, +) +from core.risk_manager import RiskManager + + +def _pos(symbol, avg_price, quantity=1, highest_price=0): + return SimpleNamespace( + symbol=symbol, avg_price=avg_price, quantity=quantity, + highest_price=highest_price, + ) + + +# ---------------------------------------------------------------- 자기상관 + +class _StubConfig: + def __init__(self, risk_params): + self.risk_params = risk_params + self.trading = {"mode": "paper"} + self.settings = {} + + +@pytest.fixture +def corr_risk_manager(): + return RiskManager(_StubConfig({ + "diversification": { + "correlation_risk": { + "enabled": True, + "lookback_days": 60, + "high_corr_threshold": 0.7, + "high_corr_scale": 0.5, + "strict": True, + }, + }, + })) + + +def test_self_correlation_is_not_counted(corr_risk_manager, monkeypatch): + """보유 중인 종목의 추가매수에서 자기 자신은 비교 대상이 아니다. + + 이 케이스가 회귀하면 corr(x,x)=1.0이 다시 잡혀 모든 추가매수가 거부된다. + 데이터 조회가 일어나면 안 되므로 DataCollector가 불리면 실패시킨다. + """ + def _boom(*args, **kwargs): # pragma: no cover - 불리면 테스트 실패 + raise AssertionError("자기 자신만 있는 경우 시세 조회가 일어나면 안 된다") + + monkeypatch.setattr("core.data_collector.DataCollector.fetch_stock", _boom) + + result = corr_risk_manager.check_correlation_risk("005930", ["005930"]) + + assert result["scale"] == 1.0 + assert not result.get("blocked") + assert result["high_corr_symbols"] == [] + + +def test_self_correlation_excluded_but_peers_still_checked(corr_risk_manager, monkeypatch): + """자기 자신만 빠지고 다른 보유 종목은 정상적으로 검사된다.""" + seen = [] + + def _fake_fetch(self, symbol, *args, **kwargs): + seen.append(symbol) + return None # 데이터 없음 → strict면 차단 + + monkeypatch.setattr("core.data_collector.DataCollector.fetch_stock", _fake_fetch) + + corr_risk_manager.check_correlation_risk("005930", ["005930", "000660"]) + + # 대상(005930)은 자기 자신 비교에서 빠지지만 target_df 조회는 여전히 필요하다. + # 핵심은 보유 목록 순회에서 005930이 다시 나오지 않는 것. + assert seen.count("005930") <= 1 + + +# ------------------------------------------------------- 바스켓 리스크 정책 + +def test_risk_block_absent_means_global_defaults(): + """`risk:` 블록이 없으면 None — 호출부가 전역 기본값을 쓰도록(기존 동작 유지).""" + assert has_risk_policy({"holdings": {}}) is False + assert basket_risk_levels({"holdings": {}}, 100_000) is None + + +def test_all_zero_risk_block_means_explicitly_no_stops(): + """전부 0인 `risk:` 블록은 '손절 없음'이라는 결정이다 — 전역 기본값으로 되돌아가면 안 된다. + + 되돌아가면 지수 ETF 적립 트랙(kr_pocket)에 단타 -3% 손절이 다시 기록된다. + """ + cfg = {"risk": {"stop_loss_pct": 0, "take_profit_pct": 0, "trailing_stop_pct": 0}} + + assert has_risk_policy(cfg) is True + levels = basket_risk_levels(cfg, 100_000) + assert levels is not None + assert levels == { + "stop_loss_price": None, + "take_profit_price": None, + "trailing_stop_price": None, + } + assert evaluate_basket_stops(cfg, [_pos("069500", 100_000)], {"069500": 50_000}) == [] + + +def test_stop_loss_level_and_breach(): + cfg = {"risk": {"stop_loss_pct": 0.25}} + + assert basket_risk_levels(cfg, 600_000)["stop_loss_price"] == 450_000 + + # 정상 조정(-10%)에는 안 걸린다 + assert evaluate_basket_stops(cfg, [_pos("005380", 600_000)], {"005380": 540_000}) == [] + + hits = evaluate_basket_stops(cfg, [_pos("005380", 600_000)], {"005380": 440_000}) + assert len(hits) == 1 + assert hits[0]["action"] == "STOP_LOSS" + assert hits[0]["symbol"] == "005380" + assert hits[0]["level"] == 450_000 + + +def test_evaluation_uses_policy_not_stale_position_column(): + """포지션에 남아 있는 옛 손절가(-3%)가 아니라 정책 비율로 판정한다.""" + cfg = {"risk": {"stop_loss_pct": 0.25}} + stale = _pos("005930", 302_000) + stale.stop_loss_price = 292_940 # 전역 -3%로 기록된 옛 값 + + # -3% 기준이면 걸리지만 정책(-25%) 기준이면 아직 아니다 + assert evaluate_basket_stops(cfg, [stale], {"005930": 250_000}) == [] + + +def test_take_profit_precedes_stop_loss(): + cfg = {"risk": {"stop_loss_pct": 0.25, "take_profit_pct": 0.10}} + hits = evaluate_basket_stops(cfg, [_pos("035720", 100_000)], {"035720": 115_000}) + assert [h["action"] for h in hits] == ["TAKE_PROFIT"] + + +def test_trailing_stop_uses_highest_price_and_needs_a_peak(): + cfg = {"risk": {"trailing_stop_pct": 0.10}} + + # 고점이 진입가 이하면 트레일링은 판단하지 않는다(손절과 구분 불가) + assert evaluate_basket_stops(cfg, [_pos("105560", 100_000)], {"105560": 80_000}) == [] + + pos = _pos("105560", 100_000, highest_price=150_000) + hits = evaluate_basket_stops(cfg, [pos], {"105560": 134_000}) + assert [h["action"] for h in hits] == ["TRAILING_STOP"] + assert hits[0]["level"] == 135_000 + + +@pytest.mark.parametrize("bad", [-0.1, 1.0, 1.5, "abc", None]) +def test_out_of_range_values_are_ignored(bad): + cfg = basket_risk_config({"risk": {"stop_loss_pct": bad}}) + assert cfg["stop_loss_pct"] is None + + +def test_invalid_entry_price_keeps_policy_but_records_no_levels(): + """진입가가 유효하지 않아도 전역 기본값으로 되돌아가지 않는다.""" + levels = basket_risk_levels({"risk": {"stop_loss_pct": 0.25}}, 0) + assert levels is not None + assert levels["stop_loss_price"] is None + + +# ------------------------------------------------------------- 재진입 차단 + +def _sell(symbol, reason, days_ago=0): + from datetime import datetime, timedelta + return SimpleNamespace( + symbol=symbol, action="SELL", reason=reason, + executed_at=datetime.now() - timedelta(days=days_ago), + ) + + +def test_cooldown_absent_means_no_block(): + assert reentry_cooldown_days({"risk": {}}) == 0 + assert symbols_in_reentry_cooldown({"risk": {}}, "acct", "paper") == {} + + +def test_risk_exit_blocks_reentry(monkeypatch): + """손절로 나간 종목은 재매수 차단 목록에 오른다. + + 이 차단이 없으면 청산으로 비워진 슬롯을 같은 사이클의 비중 교정이 곧바로 되사서 + 손실만 확정하는 왕복매매가 된다(2026-08-07 10:07 실측). + """ + monkeypatch.setattr( + "database.repositories.get_trade_history", + lambda **kw: [_sell("005380", f"리밸런싱: {RISK_EXIT_TAG} STOP_LOSS: 손절 ...")], + ) + blocked = symbols_in_reentry_cooldown( + {"risk": {"reentry_cooldown_days": 60}}, "acct", "paper", + ) + assert "005380" in blocked + + +def test_ordinary_rebalance_sell_does_not_block_reentry(monkeypatch): + """비중 초과로 판 것은 차단 대상이 아니다 — 정상 리밸런싱을 막으면 안 된다.""" + monkeypatch.setattr( + "database.repositories.get_trade_history", + lambda **kw: [_sell("055550", "리밸런싱: 비중 초과 (15.3% → 10.0%, -5.3%)")], + ) + blocked = symbols_in_reentry_cooldown( + {"risk": {"reentry_cooldown_days": 60}}, "acct", "paper", + ) + assert blocked == {} + + +def test_cooldown_query_failure_does_not_block_cycle(monkeypatch): + def _boom(**kw): + raise RuntimeError("DB 조회 실패") + + monkeypatch.setattr("database.repositories.get_trade_history", _boom) + assert symbols_in_reentry_cooldown( + {"risk": {"reentry_cooldown_days": 60}}, "acct", "paper", + ) == {} + + +def test_plan_rebalance_skips_symbol_in_cooldown(monkeypatch): + """차단 종목은 매수 후보에서 빠진다(플래너 레벨 회귀 방지).""" + from unittest.mock import MagicMock + + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "t" + rb.basket = {"risk": {"reentry_cooldown_days": 60}} + rb.account_key = "acct" + rb.execution_strategy = "acct" + rb.holdings = {"005380": 0.5, "005930": 0.5} + rb.rebalance_cfg = {"min_trade_amount": 100_000, "max_turnover_ratio": 1.0} + rb._target_stock_weight = 1.0 + rb._risk_params = {"diversification": {"min_cash_ratio": 0.0}} + rb.portfolio_mgr = MagicMock() + rb.portfolio_mgr.get_portfolio_summary.return_value = {"total_value": 10_000_000} + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + + monkeypatch.setattr( + "core.basket_rebalancer.get_all_positions", lambda **kw: [], + ) + monkeypatch.setattr( + "core.basket_rebalancer.symbols_in_reentry_cooldown", + lambda *a, **k: {"005380": "60일 재진입 차단"}, + ) + + orders = rb.plan_rebalance(prices={"005380": 400_000, "005930": 200_000}) + + symbols = {o.symbol for o in orders} + assert "005380" not in symbols, "재진입 차단 종목이 매수 후보에 남았다" + assert "005930" in symbols, "차단과 무관한 종목까지 막으면 안 된다" + + +# ----------------------------------------------- 운영 설정이 정책을 갖췄는지 + +def test_shipped_baskets_declare_risk_policy(): + """enabled 바스켓은 리스크 정책을 명시해야 한다 — 침묵하면 단타 기본값이 적힌다.""" + from core.basket_rebalancer import BasketRebalancer + + baskets = BasketRebalancer._load_baskets_config() + enabled = {n: c for n, c in baskets.items() if c.get("enabled", False)} + assert enabled, "enabled 바스켓이 없다 — 설정 로드 경로 확인 필요" + missing = [n for n, c in enabled.items() if not has_risk_policy(c)] + assert not missing, f"리스크 정책 미선언 바스켓: {missing}" + + +def test_shipped_basket_slots_are_fillable(): + """목표 비중표에 '현재 자본으로 영원히 못 채우는 슬롯'이 남아 있으면 안 된다. + + 1주 가격이 슬롯 목표금액을 넘으면 그 비중은 영구 공백이 되고, 배치율 미달로만 + 나타나 원인이 가려진다(000660이 이 상태로 2개월 방치됐다). + """ + from core.basket_deploy import effective_stock_fraction + from core.basket_rebalancer import BasketRebalancer + from config.config_loader import Config + + risk_params = Config.get().risk_params + baskets = BasketRebalancer._load_baskets_config() + cfg = baskets["kr_diversified_hold"] + capital = float(cfg.get("initial_capital") or 10_000_000) + investable = capital * effective_stock_fraction(cfg, risk_params) + + max_position_ratio = float( + (risk_params.get("diversification") or {}).get("max_position_ratio", 0.20) + ) + for symbol, weight in cfg["holdings"].items(): + slot = investable * float(weight) + # 슬롯이 단일 종목 상한 안에 있어야 하고, 최소 1주는 담을 수 있어야 한다. + assert float(weight) * effective_stock_fraction(cfg, risk_params) <= max_position_ratio, ( + f"{symbol} 목표 비중이 단일 종목 상한을 넘는다" + ) + assert slot > 0, f"{symbol} 슬롯 금액이 0" diff --git a/tests/test_blackswan_detector.py b/tests/test_blackswan_detector.py index ffe2e3bf..5abcc62c 100644 --- a/tests/test_blackswan_detector.py +++ b/tests/test_blackswan_detector.py @@ -1,5 +1,6 @@ """BlackSwanDetector 단위 테스트""" from datetime import datetime, timedelta +from types import SimpleNamespace import pytest @@ -27,3 +28,57 @@ def test_check_stock_no_trigger(detector): """급락 아닐 때 triggered False""" r = detector.check_stock("005930", 50_000, 49_000) assert r["triggered"] is False + + +def test_detector_reads_all_blackswan_risk_params(): + """risk_params.blackswan이 감지·cooldown·recovery의 실제 운영값이다.""" + config = SimpleNamespace( + risk_params={ + "blackswan": { + "single_stock_threshold": -0.04, + "portfolio_threshold": -0.025, + "consecutive_days": 4, + "consecutive_threshold": -0.015, + "cooldown_minutes": 7, + "recovery_minutes": 11, + "recovery_scale": 0.25, + } + }, + # risk_params가 있으면 기존 settings 값보다 우선해야 한다. + trading={ + "blackswan_recovery_minutes": 99, + "blackswan_recovery_scale": 0.9, + }, + ) + + configured = BlackSwanDetector(config) + + assert configured.single_stock_threshold == -0.04 + assert configured.portfolio_threshold == -0.025 + assert configured.consecutive_days == 4 + assert configured.consecutive_threshold == -0.015 + assert configured.cooldown_minutes == 7 + assert configured.recovery_minutes == 11 + assert configured.recovery_scale == 0.25 + + before = datetime.now() + result = configured.check_stock("005930", 95_000, 100_000) + assert result["triggered"] is True + assert configured.is_on_cooldown() is True + remaining = configured._cooldown_until - before + assert timedelta(minutes=6, seconds=55) <= remaining <= timedelta(minutes=7, seconds=5) + + +def test_detector_keeps_legacy_recovery_fallback_when_risk_keys_missing(): + config = SimpleNamespace( + risk_params={"blackswan": {}}, + trading={ + "blackswan_recovery_minutes": 33, + "blackswan_recovery_scale": 0.4, + }, + ) + + configured = BlackSwanDetector(config) + + assert configured.recovery_minutes == 33 + assert configured.recovery_scale == 0.4 diff --git a/tests/test_cash_flows.py b/tests/test_cash_flows.py index c1f2ced6..e3cc2112 100644 --- a/tests/test_cash_flows.py +++ b/tests/test_cash_flows.py @@ -81,22 +81,40 @@ def _pm(monkeypatch, account, initial=300_000, cash_delta=0.0, deposits=0.0, """요약 산식 검증용 PortfolioManager — 저장소 의존을 모두 결정론으로 고정.""" import core.portfolio_manager as pm_mod - monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + monkeypatch.setattr( + pm_mod, + "get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) pm = PortfolioManager(account_key=account, initial_capital=initial) - monkeypatch.setattr(pm_mod, "get_all_positions", lambda account_key=None: []) + monkeypatch.setattr( + pm_mod, + "get_all_positions", + lambda account_key=None, mode="paper": [], + ) monkeypatch.setattr( pm_mod, "get_trade_cash_summary", lambda mode=None, account_key=None: {"cash_delta": cash_delta}, ) - monkeypatch.setattr(pm_mod, "get_cash_flow_total", lambda account_key="": deposits) monkeypatch.setattr( - pm_mod, "get_latest_snapshot_summary", lambda account_key="": prev_snapshot, + pm_mod, + "get_cash_flow_total", + lambda account_key="", mode="paper": deposits, ) monkeypatch.setattr( - pm_mod, "get_cash_flow_total_between", lambda ak, a, u: flow_since, + pm_mod, + "get_latest_snapshot_summary", + lambda account_key="", mode="paper": prev_snapshot, ) monkeypatch.setattr( - pm_mod, "get_max_cumulative_return", lambda account_key="": hist_max_cum, + pm_mod, + "get_cash_flow_total_between", + lambda ak, a, u, mode="paper": flow_since, + ) + monkeypatch.setattr( + pm_mod, + "get_max_cumulative_return", + lambda account_key="", mode="paper": hist_max_cum, ) return pm @@ -111,7 +129,11 @@ class TestBasketCapitalResolution: def test_basket_key_resolves_basket_capital(self, monkeypatch): import core.portfolio_manager as pm_mod - monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + monkeypatch.setattr( + pm_mod, + "get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) from unittest.mock import patch with patch( "core.basket_rebalancer.BasketRebalancer._load_baskets_config", @@ -123,7 +145,11 @@ def test_basket_key_resolves_basket_capital(self, monkeypatch): def test_unknown_basket_falls_back_to_global(self, monkeypatch): import core.portfolio_manager as pm_mod - monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + monkeypatch.setattr( + pm_mod, + "get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) from unittest.mock import patch with patch( "core.basket_rebalancer.BasketRebalancer._load_baskets_config", @@ -135,14 +161,22 @@ def test_unknown_basket_falls_back_to_global(self, monkeypatch): def test_non_basket_key_unchanged(self, monkeypatch): import core.portfolio_manager as pm_mod - monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + monkeypatch.setattr( + pm_mod, + "get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) pm = PortfolioManager(account_key="scoring") assert pm.initial_capital >= 1_000_000 # 기존 동작 그대로 def test_explicit_capital_still_wins(self, monkeypatch): import core.portfolio_manager as pm_mod - monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None) + monkeypatch.setattr( + pm_mod, + "get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) pm = PortfolioManager( account_key="basket_rebalance:kr_pocket", initial_capital=777, ) @@ -250,7 +284,11 @@ def test_net_zero_flows_still_twr_branch(self, monkeypatch): prev_snapshot=prev, flow_since=-100_000, hist_max_cum=25.0, ) import core.portfolio_manager as pm_mod - monkeypatch.setattr(pm_mod, "has_cash_flows", lambda account_key="": True) + monkeypatch.setattr( + pm_mod, + "has_cash_flows", + lambda account_key="", mode="paper": True, + ) out = pm.get_portfolio_summary() # V=300k, 유입 -100k → r = 300/(400-100)-1 = 0 → 누적 25% 유지 (legacy면 0%로 붕괴) assert out["total_return"] == pytest.approx(25.0) diff --git a/tests/test_config_auto_entry.py b/tests/test_config_auto_entry.py index e6d2b9a0..872fa2af 100644 --- a/tests/test_config_auto_entry.py +++ b/tests/test_config_auto_entry.py @@ -345,9 +345,8 @@ def test_undeclared_account_env_warns(self, monkeypatch, caplog): _override_with_env({"kis_api": {"accounts": {}}}) assert any("KIS_ACCOUNT_NO_GHOST_STRATEGY" in r.message for r in caplog.records) - def test_live_default_fallback_warns_once(self, caplog): - """live에서 미선언 전략이 기본 계좌로 폴백하면 1회 경고(공유 가시화).""" - import logging + def test_live_default_fallback_is_blocked(self): + """live에서 미선언 전략은 기본 실계좌로 침묵 폴백할 수 없다.""" from config.config_loader import Config cfg = Config.__new__(Config) @@ -355,12 +354,8 @@ def test_live_default_fallback_warns_once(self, caplog): "trading": {"mode": "live"}, "kis_api": {"account_no": "1111-01", "accounts": {}}, } - Config._default_account_warned = set() - with caplog.at_level(logging.WARNING, logger="config_loader"): - assert cfg.get_account_no("scoring") == "1111-01" - assert cfg.get_account_no("scoring") == "1111-01" # 2회째는 경고 없음 - warns = [r for r in caplog.records if "기본 계좌로 폴백" in r.message] - assert len(warns) == 1 + with pytest.raises(ValueError, match="기본 계좌 폴백은 허용되지 않습니다"): + cfg.get_account_no("scoring") def test_paper_default_fallback_silent(self, caplog): """paper에서는 기본 계좌 폴백이 정상 동작 — 경고 없음.""" @@ -372,7 +367,6 @@ def test_paper_default_fallback_silent(self, caplog): "trading": {"mode": "paper"}, "kis_api": {"account_no": "1111-01", "accounts": {}}, } - Config._default_account_warned = set() with caplog.at_level(logging.WARNING, logger="config_loader"): assert cfg.get_account_no("scoring") == "1111-01" assert not [r for r in caplog.records if "기본 계좌" in r.message] diff --git a/tests/test_config_duplicate_keys.py b/tests/test_config_duplicate_keys.py new file mode 100644 index 00000000..c0c10f4d --- /dev/null +++ b/tests/test_config_duplicate_keys.py @@ -0,0 +1,34 @@ +"""중복 YAML 키는 마지막 값으로 조용히 덮지 않고 설정 로드 단계에서 차단한다.""" + +import pytest +import yaml + +from config.config_loader import load_yaml + + +def test_duplicate_yaml_key_is_rejected(tmp_path): + path = tmp_path / "duplicate.yaml" + path.write_text( + "strategy:\n threshold: 1\nstrategy:\n threshold: 2\n", + encoding="utf-8", + ) + + with pytest.raises(yaml.constructor.ConstructorError, match="duplicate key"): + load_yaml(path) + + +def test_nested_duplicate_yaml_key_is_rejected(tmp_path): + path = tmp_path / "nested_duplicate.yaml" + path.write_text( + "strategy:\n threshold: 1\n threshold: 2\n", + encoding="utf-8", + ) + + with pytest.raises(yaml.constructor.ConstructorError, match="threshold"): + load_yaml(path) + + +def test_project_strategy_config_has_unique_keys(): + loaded = load_yaml("config/strategies.yaml") + assert "trend_pullback" in loaded + assert loaded["trend_pullback"]["sma_period"] == 60 diff --git a/tests/test_config_risk_validation.py b/tests/test_config_risk_validation.py new file mode 100644 index 00000000..afd24290 --- /dev/null +++ b/tests/test_config_risk_validation.py @@ -0,0 +1,214 @@ +"""실거래 손실 한도에 직접 영향을 주는 설정은 로드 시 fail-closed 검증한다.""" + +import copy + +import pytest + +from config.config_loader import Config + + +def _base_risk_params() -> dict: + return { + "position_sizing": { + "initial_capital": 10_000_000, + "max_risk_per_trade": 0.01, + "signal_scaling": { + "enabled": True, + "min_scale": 0.5, + "max_scale": 1.0, + }, + }, + "drawdown": { + "max_portfolio_mdd": 0.15, + "max_daily_loss": 0.03, + }, + "diversification": { + "max_position_ratio": 0.2, + "max_investment_ratio": 0.7, + "max_sector_ratio": 0.4, + "min_cash_ratio": 0.2, + }, + "blackswan": { + "single_stock_threshold": -0.05, + "portfolio_threshold": -0.03, + "consecutive_days": 3, + "consecutive_threshold": -0.02, + "cooldown_minutes": 60, + "recovery_minutes": 120, + "recovery_scale": 0.5, + }, + } + + +def _validate(risk_params: dict, settings: dict | None = None) -> None: + config = object.__new__(Config) + config._risk_params = risk_params + config._settings = settings or {"trading": {"mode": "paper"}} + config._validate_critical_params() + + +def test_valid_conservative_risk_config_is_accepted(): + _validate(_base_risk_params()) + + +@pytest.mark.parametrize( + ("section", "key", "bad_value"), + [ + ("position_sizing", "max_risk_per_trade", 0), + ("position_sizing", "max_risk_per_trade", 0.051), + ("position_sizing", "max_risk_per_trade", float("nan")), + ("drawdown", "max_daily_loss", -0.01), + ("drawdown", "max_portfolio_mdd", float("inf")), + ("diversification", "max_position_ratio", 1.1), + ("diversification", "min_cash_ratio", -0.1), + ], +) +def test_invalid_loss_limit_ratios_are_rejected(section, key, bad_value): + params = copy.deepcopy(_base_risk_params()) + params[section][key] = bad_value + + with pytest.raises(ValueError, match=key): + _validate(params) + + +def test_signal_scale_cannot_raise_position_above_risk_budget(): + params = _base_risk_params() + params["position_sizing"]["signal_scaling"]["max_scale"] = 1.5 + + with pytest.raises(ValueError, match="max_scale"): + _validate(params) + + +def test_signal_scale_min_must_not_exceed_max(): + params = _base_risk_params() + params["position_sizing"]["signal_scaling"]["min_scale"] = 0.9 + params["position_sizing"]["signal_scaling"]["max_scale"] = 0.5 + + with pytest.raises(ValueError, match="min_scale ≤ max_scale"): + _validate(params) + + +def test_holding_period_income_tax_rate_must_be_a_ratio(): + params = _base_risk_params() + params["transaction_costs"] = { + "holding_period_income_tax": { + "enabled": True, + "rate": 1.54, + "symbols": ["357870"], + }, + } + + with pytest.raises(ValueError, match="holding_period_income_tax.rate"): + _validate(params) + + +@pytest.mark.parametrize("symbols", [None, [], "357870"]) +def test_enabled_holding_period_income_tax_requires_symbol_list(symbols): + params = _base_risk_params() + params["transaction_costs"] = { + "holding_period_income_tax": { + "enabled": True, + "rate": 0.154, + "symbols": symbols, + }, + } + + with pytest.raises(ValueError, match="holding_period_income_tax.symbols"): + _validate(params) + + +@pytest.mark.parametrize( + ("key", "bad_value"), + [ + ("single_stock_threshold", 0), + ("portfolio_threshold", -1), + ("consecutive_threshold", float("nan")), + ("consecutive_days", 0), + ("cooldown_minutes", -1), + ("recovery_minutes", 1.5), + ("recovery_scale", 1.1), + ], +) +def test_invalid_blackswan_controls_are_rejected(key, bad_value): + params = _base_risk_params() + params["blackswan"][key] = bad_value + + with pytest.raises(ValueError, match=key): + _validate(params) + + +@pytest.mark.parametrize( + ("key", "bad_value"), + [ + ("pending_order_ttl_seconds", 59), + ("pending_order_ttl_seconds", float("nan")), + ("ledger_reconcile_guard_ttl_seconds", 3_599), + ("skip_earnings_days", -1), + ("skip_earnings_days", True), + ], +) +def test_invalid_trading_safety_controls_are_rejected(key, bad_value): + settings = {"trading": {"mode": "paper", key: bad_value}} + + with pytest.raises(ValueError, match=key): + _validate(_base_risk_params(), settings) + + +@pytest.mark.parametrize( + ("key", "bad_value"), + [ + ("max_calls_per_sec", 0), + ("max_calls_per_sec", float("inf")), + ("max_calls_per_min", 0), + ("max_calls_per_min", 1.5), + ], +) +def test_invalid_kis_rate_limits_are_rejected(key, bad_value): + settings = { + "trading": {"mode": "paper"}, + "kis_api": {key: bad_value}, + } + + with pytest.raises(ValueError, match=key): + _validate(_base_risk_params(), settings) + + +@pytest.mark.parametrize( + ("section", "key", "bad_value"), + [ + ("correlation_risk", "high_corr_threshold", float("nan")), + ("correlation_risk", "high_corr_scale", 0), + ("correlation_risk", "lookback_days", 29), + ("gap_risk", "gap_down_threshold", 0), + ("gap_risk", "gap_up_entry_block", float("inf")), + ("performance_degradation", "min_win_rate", float("nan")), + ("performance_degradation", "recent_trades", 4), + ], +) +def test_invalid_entry_filter_controls_are_rejected(section, key, bad_value): + params = _base_risk_params() + if section == "correlation_risk": + params["diversification"]["correlation_risk"] = { + "enabled": True, + "high_corr_threshold": 0.7, + "high_corr_scale": 0.5, + "lookback_days": 60, + } + params["diversification"]["correlation_risk"][key] = bad_value + elif section == "gap_risk": + params["gap_risk"] = { + "enabled": True, + "gap_down_threshold": -0.03, + "gap_up_entry_block": 0.05, + } + params["gap_risk"][key] = bad_value + else: + params["performance_degradation"] = { + "enabled": True, + "min_win_rate": 0.35, + "recent_trades": 20, + } + params["performance_degradation"][key] = bad_value + + with pytest.raises(ValueError, match=key): + _validate(params) diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py index 26b9bb5d..0c4ed41e 100644 --- a/tests/test_critical_fixes.py +++ b/tests/test_critical_fixes.py @@ -334,8 +334,16 @@ def generate_signal(self, df, symbol=None): lambda cfg: SimpleNamespace(resolve=lambda: ["005930"]), ) monkeypatch.setattr(main_mod, "_get_strategy", lambda strategy: FakeStrategy()) - monkeypatch.setattr(repositories, "get_all_positions", lambda account_key=None: []) - monkeypatch.setattr(repositories, "get_position", lambda symbol, account_key="": None) + monkeypatch.setattr( + repositories, + "get_all_positions", + lambda account_key=None, mode="paper": [], + ) + monkeypatch.setattr( + repositories, + "get_position", + lambda symbol, account_key="", mode="paper": None, + ) main_mod.run_paper_trading(SimpleNamespace(strategy="scoring")) @@ -359,6 +367,12 @@ def test_live_liquidate_syncs_broker_positions_before_loading_db_positions(self, monkeypatch.setattr(main_mod.Config, "get", lambda: config) monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") + def fake_set_trading_halt(reason, *, source, mode, detail=None): + calls.append(("halt", source, mode)) + return {"halted": True, "event_id": 101, "reason": reason} + + monkeypatch.setattr(repositories, "set_trading_halt", fake_set_trading_halt) + class FakePortfolio: def __init__(self, cfg, account_key=""): self.account_key = account_key @@ -371,9 +385,13 @@ def sync_with_broker(self, auto_correct=True): "message": "KIS-only 포지션 DB 반영", } - def fake_get_all_positions(): + def fake_get_all_positions(mode="paper"): + assert mode == "live" calls.append(("positions",)) - assert calls[0] == ("sync", "", True) + assert calls[:2] == [ + ("halt", "main.run_emergency_liquidate", "live"), + ("sync", "", True), + ] return [ SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""), ] @@ -408,7 +426,11 @@ def execute_sell(self, symbol, price, quantity=None, reason="", strategy=""): summary = main_mod.run_emergency_liquidate(SimpleNamespace(confirm_live=True)) - assert calls[:2] == [("sync", "", True), ("positions",)] + assert calls[:3] == [ + ("halt", "main.run_emergency_liquidate", "live"), + ("sync", "", True), + ("positions",), + ] assert sells == [{ "account_key": "", "symbol": "005930", @@ -421,8 +443,8 @@ def execute_sell(self, symbol, price, quantity=None, reason="", strategy=""): assert summary["succeeded"] == 1 assert summary["failed"] == 0 - def test_live_liquidate_does_not_fallback_to_avg_price_when_current_price_missing(self, monkeypatch): - """live 긴급 청산은 현재가 조회 실패 시 평균단가 지정가 매도를 내지 않는다.""" + def test_live_liquidate_uses_avg_price_reference_for_market_exit_when_current_price_missing(self, monkeypatch): + """live 긴급 청산은 현재가가 없어도 평균단가를 참조가로 시장가 청산을 시도한다.""" import main as main_mod import database.repositories as repositories @@ -437,11 +459,16 @@ def test_live_liquidate_does_not_fallback_to_avg_price_when_current_price_missin monkeypatch.setattr(main_mod.Config, "get", lambda: config) monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") + monkeypatch.setattr( + repositories, + "set_trading_halt", + lambda *args, **kwargs: {"halted": True, "event_id": 102}, + ) monkeypatch.setattr(main_mod, "_sync_live_positions_before_liquidation", lambda cfg: []) monkeypatch.setattr( repositories, "get_all_positions", - lambda: [ + lambda mode="paper": [ SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""), ], ) @@ -466,7 +493,7 @@ def execute_sell(self, symbol, price, quantity=None, reason="", strategy=""): "reason": reason, "strategy": strategy, }) - raise AssertionError("현재가 실패 시 live 매도 주문을 호출하면 안 됨") + return {"success": True} class FakeNotifier: def __init__(self, cfg): @@ -482,17 +509,23 @@ def send_message(self, text, critical=False): summary = main_mod.run_emergency_liquidate(SimpleNamespace(confirm_live=True)) assert calls == [("price", "005930", "12345678-01")] - assert sells == [] + assert sells == [{ + "symbol": "005930", + "price": 60_000, + "quantity": None, + "reason": "긴급 전량 청산 (--mode liquidate)", + "strategy": "emergency_liquidate", + }] assert summary["attempted"] == 1 - assert summary["succeeded"] == 0 - assert summary["failed"] == 1 + assert summary["succeeded"] == 1 + assert summary["failed"] == 0 assert summary["details"] == [{ "symbol": "005930", "account_key": "", - "status": "failed", - "reason": "실전 긴급 청산 현재가 조회 실패", + "status": "success", + "price": 60_000, }] - assert notifications and "실패 상세" in notifications[0]["text"] + assert notifications and notifications[0]["critical"] is True def test_live_liquidate_aborts_when_broker_sync_fails_before_position_load(self, monkeypatch): """live 긴급 청산 전 KIS↔DB 동기화 실패가 남으면 stale DB 포지션만으로 진행하지 않는다.""" @@ -510,6 +543,11 @@ def test_live_liquidate_aborts_when_broker_sync_fails_before_position_load(self, monkeypatch.setattr(main_mod.Config, "get", lambda: config) monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") + monkeypatch.setattr( + repositories, + "set_trading_halt", + lambda *args, **kwargs: {"halted": True, "event_id": 103}, + ) class FakePortfolio: def __init__(self, cfg, account_key=""): @@ -543,6 +581,11 @@ def test_live_liquidate_aborts_when_broker_sync_partially_corrects_positions(sel monkeypatch.setattr(main_mod.Config, "get", lambda: config) monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") + monkeypatch.setattr( + repositories, + "set_trading_halt", + lambda *args, **kwargs: {"halted": True, "event_id": 104}, + ) class FakePortfolio: def __init__(self, cfg, account_key=""): @@ -626,7 +669,7 @@ def test_liquidate_summary_reports_sell_failure(self, monkeypatch): monkeypatch.setattr( repositories, "get_all_positions", - lambda: [ + lambda mode="paper": [ SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""), ], ) @@ -663,7 +706,7 @@ def test_liquidate_summary_sends_critical_notification(self, monkeypatch): monkeypatch.setattr( repositories, "get_all_positions", - lambda: [ + lambda mode="paper": [ SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""), ], ) @@ -788,8 +831,8 @@ def test_web_dashboard_defaults_to_loopback(self, monkeypatch): assert host == "127.0.0.1" assert port == 8080 - def test_web_dashboard_uses_configured_or_explicit_host(self, monkeypatch): - """외부 바인드는 설정 또는 CLI에서 명시한 경우에만 사용한다.""" + def test_web_dashboard_rejects_external_host(self, monkeypatch): + """인증 없는 금융 대시보드는 명시해도 외부 주소에 바인드하지 않는다.""" from monitoring import web_dashboard as wd monkeypatch.setattr( @@ -798,7 +841,8 @@ def test_web_dashboard_uses_configured_or_explicit_host(self, monkeypatch): lambda: SimpleNamespace(settings={"dashboard": {"host": "0.0.0.0", "port": 9090}}), ) - assert wd.resolve_dashboard_bind() == ("0.0.0.0", 9090) + with pytest.raises(ValueError, match="loopback"): + wd.resolve_dashboard_bind() assert wd.resolve_dashboard_bind(host="127.0.0.1", port=7070) == ("127.0.0.1", 7070) def test_main_dashboard_passes_host_and_port(self, monkeypatch): diff --git a/tests/test_dashboard_basket_evaluation.py b/tests/test_dashboard_basket_evaluation.py index 5c1cdbc2..8bfbfc42 100644 --- a/tests/test_dashboard_basket_evaluation.py +++ b/tests/test_dashboard_basket_evaluation.py @@ -108,7 +108,7 @@ async def run(): html = await res.text() finally: await client.close() - assert "basketEval" in html and "승격 진행률" in html + assert "basketEval" in html and "모의 운용 검증" in html asyncio.run(run()) diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py index d40cd6a3..a53aad94 100644 --- a/tests/test_dashboard_baskets.py +++ b/tests/test_dashboard_baskets.py @@ -6,7 +6,7 @@ import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import patch import pytest @@ -27,8 +27,11 @@ def _seed_pocket(basket_name): init_database() session = get_session() try: + snapshot_at = (datetime.now() - timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) session.add(PortfolioSnapshot( - account_key=acct, date=datetime(2026, 7, 6), + account_key=acct, date=snapshot_at, total_value=400_126, cash=171_846, invested=228_280, cumulative_return=0.04, mdd=0.0, peak_value=400_126, )) @@ -39,7 +42,11 @@ def _seed_pocket(basket_name): session.commit() finally: session.close() - record_cash_flow(100_000, account_key=acct, occurred_at=datetime(2026, 7, 6, 9, 0)) + record_cash_flow( + 100_000, + account_key=acct, + occurred_at=snapshot_at.replace(hour=9), + ) return acct @@ -48,9 +55,15 @@ def _cfg(basket_name): basket_name: { "name": "소액 적립 (KODEX200 50/50)", "enabled": True, + "primary": True, + "purpose": "월 적립 중심", + "contribution_plan": { + "enabled": True, "cadence": "monthly", "amount": 100_000, + }, "initial_capital": 300_000, "target_stock_weight": 0.5, "holdings": {"069500": 1.0}, + "holding_names": {"069500": "KODEX 200"}, } } @@ -86,9 +99,15 @@ def test_principal_snapshot_deployment_positions(self): assert b["design_fraction"] == pytest.approx(0.5) # 보유 assert b["positions"] == [{ - "symbol": "069500", "quantity": 1, + "symbol": "069500", "name": "KODEX 200", "quantity": 1, "avg_price": 128_135.0, "invested": 128_135.0, }] + assert data["mode"] in {"paper", "live"} + assert b["is_primary"] is True + assert b["purpose"] == "월 적립 중심" + assert b["contribution_plan"] == { + "enabled": True, "cadence": "monthly", "amount": 100_000.0, + } def test_no_snapshot_yet_is_null_not_crash(self): name = "kr_pocket_empty" # 시드 없음 — 운영 전 상태 @@ -195,6 +214,31 @@ async def run(): asyncio.run(run()) + @pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") + def test_snapshots_endpoint_clamps_excessive_range(self): + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + + async def run(): + with patch.object( + wd, + "get_snapshots_json", + return_value={"snapshots": [], "days": 3650, "mode": "paper"}, + ) as mocked: + app = wd.create_app() + client = TestClient(TestServer(app)) + await client.start_server() + try: + response = await client.get("/api/snapshots?days=999999") + assert response.status == 200 + await response.json() + finally: + await client.close() + mocked.assert_called_once_with(days=3650, account_key=None) + + asyncio.run(run()) + @pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") def test_empty_account_key_filters_default_account_only(self): # account_key=(빈 값)은 기본 계정('')만 — 무필터(전 계정 혼합)로 강등되면 @@ -209,7 +253,7 @@ def test_empty_account_key_filters_default_account_only(self): session = get_session() try: session.add(PortfolioSnapshot( - account_key="", date=_dt(2026, 7, 7), + account_key="", date=_dt.now() - timedelta(days=1), total_value=10_000_000, cash=10_000_000, invested=0, )) session.commit() @@ -236,11 +280,141 @@ def test_html_page_contains_basket_tracks_section(): from monitoring.web_dashboard import _html_page html = _html_page() - assert "basketTracks" in html # 섹션 - assert "/api/baskets" in html # 폴링 대상 - assert "chartAccount" in html # 차트 계정 선택기 - assert "/api/deposit" in html # 웹 입금 폼 - assert "depositOverlay" in html # 입금 모달 + assert "basketTracks" in html # 주력 포트폴리오 섹션 + assert "chartAccount" in html # 장기 차트 계정 선택기 + assert 'id="decisionTitle"' in html # 오늘의 단일 판단 + assert '0 은 True, # nan<=0 은 False라 기존 양수 검사를 둘 다 통과해 무한대/NaN 입금이 기록되던 @@ -381,6 +592,7 @@ async def run(): headers={ "Content-Type": "application/json", "X-Requested-With": "quant-dashboard", + "Idempotency-Key": "test-nonfinite-deposit-0001", }, ) assert res.status == 400, f"payload {payload!r} → {res.status}" @@ -438,4 +650,52 @@ async def run(): assert data["flows"][0]["amount"] == 100000 assert data["flows"][0]["note"] == "7월 적립" - asyncio.run(run()) \ No newline at end of file + asyncio.run(run()) + + +@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") +def test_cash_flows_endpoint_uses_active_ledger_mode(): + """실전 대시보드에서 동일 바스켓의 paper 입금 내역을 노출하지 않는다.""" + import asyncio + from types import SimpleNamespace + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + from database.repositories import record_cash_flow + from core.basket_rebalancer import rebalance_live_strategy_id + + name = "kr_pocket_flows_mode_isolation" + account_key = rebalance_live_strategy_id(name) + init_database() + record_cash_flow( + 100_000, + account_key=account_key, + occurred_at=datetime(2026, 7, 6, 9, 0), + note="paper-only", + mode="paper", + ) + record_cash_flow( + 200_000, + account_key=account_key, + occurred_at=datetime(2026, 7, 6, 10, 0), + note="live-only", + mode="live", + ) + + async def run(): + app = wd.create_app() + client = TestClient(TestServer(app)) + await client.start_server() + try: + with patch.object( + wd.Config, + "get", + return_value=SimpleNamespace(trading={"mode": "live"}), + ): + res = await client.get("/api/cash_flows?basket=" + name) + assert res.status == 200 + data = await res.json() + finally: + await client.close() + assert [flow["note"] for flow in data["flows"]] == ["live-only"] + + asyncio.run(run()) diff --git a/tests/test_deployment_ratchet.py b/tests/test_deployment_ratchet.py new file mode 100644 index 00000000..d6019ee1 --- /dev/null +++ b/tests/test_deployment_ratchet.py @@ -0,0 +1,265 @@ +"""현금 래칫 · 결측 경보 피로 · 적립 이행 점검 회귀 테스트. + +배경(2026-08-26 3주 점검): 트랙이 표면상 정상(오류 0건, 스냅샷 저장 정상, 헬스 ✅ OK) +인데 설계대로 굴러가지 않는 상태가 3주간 감지되지 않았다. 세 가지가 겹쳤다. + + 1) 현금 래칫 — 비중 초과 매도는 min_trade를 넘겨 집행되는데(현금 증가), 그 현금을 + 되돌리는 매수는 9종목에 얇게 퍼져 전부 min_trade 미만이라 집행되지 않았다. + 배치율 61.0% → 54.9% 단조 감소, 19거래일간 재투자 0건. 그 사이 KOSPI가 +8.17% + 반등해 유휴 현금이 반등분의 45%를 깎아먹었다. + 2) 결측 경보 피로 — 복구 불가능한 8/18 결측 하나가 3주간 16건의 warning을 만들었다. + 3) 적립 미실행 — kr_pocket 입금이 47일간 0건인데 헬스는 계속 ✅ OK였다. +""" + +from datetime import date, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.basket_rebalancer import BasketRebalancer +from core.operator_health import summarize_contribution_plan + + +def _pos(symbol, avg_price, quantity): + return SimpleNamespace(symbol=symbol, avg_price=avg_price, quantity=quantity) + + +def _rebalancer(*, holdings, target_stock_weight, min_trade, positions, + drift_threshold=0.08, deployment_band=0.03, turnover=1.0): + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "t" + rb.basket = {"target_stock_weight": target_stock_weight, "risk": {}} + rb.holdings = holdings + rb.account_key = "acct" + rb.execution_strategy = "acct" + rb.rebalance_cfg = { + "min_trade_amount": min_trade, + "max_turnover_ratio": turnover, + "drift_threshold": drift_threshold, + "deployment_band": deployment_band, + } + rb._target_stock_weight = target_stock_weight + rb._risk_params = {"diversification": {"min_cash_ratio": 0.0}} + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + rb._positions = positions + return rb + + +@pytest.fixture +def wired(monkeypatch): + def _apply(rb, total_value): + rb.portfolio_mgr = MagicMock() + rb.portfolio_mgr.get_portfolio_summary.return_value = {"total_value": total_value} + monkeypatch.setattr( + "core.basket_rebalancer.get_all_positions", lambda **kw: rb._positions, + ) + monkeypatch.setattr( + "core.basket_rebalancer.symbols_in_reentry_cooldown", lambda *a, **k: {}, + ) + return _apply + + +# ------------------------------------------------------------- 현금 래칫 + +def test_thin_shortfall_is_topped_up_not_left_as_idle_cash(wired): + """종목별 드리프트가 전부 min_trade 미만이어도 집계 미달은 채운다. + + 회귀하면 매도로 늘어난 현금이 영영 재투자되지 않는다(2026-08 실측). + """ + holdings = {"A%d" % i: 1 / 9 for i in range(9)} + prices = {"A%d" % i: 100_000 for i in range(9)} + positions = [_pos("A%d" % i, 100_000, 6) for i in range(9)] # 5,400,000 + rb = _rebalancer(holdings=holdings, target_stock_weight=0.60, + min_trade=200_000, positions=positions) + wired(rb, 10_000_000) + + should, reason = rb.should_rebalance(prices) + assert should is True and "집계 배치율" in reason + + orders = rb.plan_rebalance(prices) + assert orders, "집계 미달이 보충되지 않았다 — 현금 래칫 회귀" + bought = sum(o.quantity * o.price for o in orders if o.action == "BUY") + shortfall = 6_000_000 - 5_400_000 + assert bought > 0 + assert abs(shortfall - bought) < shortfall, "매수가 격차를 줄이지 못했다" + + +def test_topup_never_pushes_a_symbol_past_its_own_target(wired): + """집계를 맞추자고 개별 종목 비중을 무너뜨리지 않는다. + + 1주 단가가 총자산 대비 클 때 이 가드가 없으면 한 종목에 몰아 사고, 다음 사이클이 + 그걸 되팔아 왕복매매가 된다. + """ + holdings = {"CHEAP": 0.5, "PRICEY": 0.5} + prices = {"CHEAP": 10_000, "PRICEY": 400_000} + positions = [_pos("CHEAP", 10_000, 20), _pos("PRICEY", 400_000, 1)] + rb = _rebalancer(holdings=holdings, target_stock_weight=0.95, + min_trade=50_000, positions=positions, drift_threshold=0.08) + wired(rb, 1_000_000) + + investable = 1_000_000 * 0.95 + for o in rb.plan_rebalance(prices): + if o.action != "BUY": + continue + held = next((p.quantity * prices[p.symbol] for p in positions + if p.symbol == o.symbol), 0) + projected_w = (held + o.quantity * o.price) / investable + assert projected_w <= 0.5 + 0.08 + 1e-9, ( + "%s 보충 후 비중 %.1f%%가 목표+허용을 넘었다" % (o.symbol, projected_w * 100) + ) + + +def test_topup_skips_symbols_in_reentry_cooldown(monkeypatch): + """손절로 나간 종목은 배치율 보충 경로로도 되사지 않는다.""" + holdings = {"A": 0.5, "B": 0.5} + prices = {"A": 100_000, "B": 100_000} + positions = [_pos("A", 100_000, 20), _pos("B", 100_000, 20)] + rb = _rebalancer(holdings=holdings, target_stock_weight=0.60, + min_trade=200_000, positions=positions) + rb.portfolio_mgr = MagicMock() + rb.portfolio_mgr.get_portfolio_summary.return_value = {"total_value": 10_000_000} + monkeypatch.setattr("core.basket_rebalancer.get_all_positions", lambda **kw: positions) + monkeypatch.setattr( + "core.basket_rebalancer.symbols_in_reentry_cooldown", + lambda *a, **k: {"A": "60일 재진입 차단"}, + ) + assert "A" not in {o.symbol for o in rb.plan_rebalance(prices) if o.action == "BUY"} + + +def test_no_topup_when_deployment_is_within_band(wired): + """설계 배치율 안에 있으면 아무것도 사지 않는다(불필요한 회전 억제).""" + holdings = {"A%d" % i: 1 / 9 for i in range(9)} + prices = {"A%d" % i: 100_000 for i in range(9)} + positions = [_pos("A%d" % i, 100_000, 6) for i in range(9)] + rb = _rebalancer(holdings=holdings, target_stock_weight=0.545, + min_trade=200_000, positions=positions) + wired(rb, 10_000_000) + should, _ = rb.should_rebalance(prices) + assert should is False + assert rb.plan_rebalance(prices) == [] + + +def test_deployment_gap_sign(wired): + rb = _rebalancer(holdings={"A": 1.0}, target_stock_weight=0.60, + min_trade=100_000, positions=[_pos("A", 100_000, 50)]) + wired(rb, 10_000_000) + assert rb._deployment_gap({"A": 100_000}) == pytest.approx(-0.10, abs=1e-9) + + +# --------------------------------------------------- 결측 경보 중복 억제 + +class _FakeQuery: + def __init__(self, rows): + self._rows = rows + + def filter(self, *a, **k): + return self + + def all(self): + return self._rows + + +class _FakeSession: + def __init__(self, rows): + self._rows = rows + + def query(self, *a, **k): + return _FakeQuery(self._rows) + + def close(self): + pass + + +def test_already_reported_gap_is_not_realarmed(monkeypatch): + """같은 결측일은 한 번만 알린다 — 3주간 16건 중복 경보 재발 방지.""" + from core import cycle_observability as co + + rows = [("⚠️ NAV 스냅샷 결측 1일: 2026-08-18 — 재실행 권장",)] + monkeypatch.setattr("database.models.get_session", lambda: _FakeSession(rows)) + out = co.unreported_snapshot_gaps("acct", [date(2026, 8, 18), date(2026, 8, 25)]) + assert out == [date(2026, 8, 25)], "이미 알린 결측이 다시 경보로 나갔다" + + +def test_gap_dedupe_falls_back_to_reporting_on_query_failure(monkeypatch): + """조회가 실패하면 경보를 삼키지 않는다 — 중복이 침묵보다 낫다.""" + from core import cycle_observability as co + + def _boom(): + raise RuntimeError("db down") + + monkeypatch.setattr("database.models.get_session", _boom) + gaps = [date(2026, 8, 18)] + assert co.unreported_snapshot_gaps("acct", gaps) == gaps + + +def test_no_gaps_is_noop(): + from core import cycle_observability as co + assert co.unreported_snapshot_gaps("acct", []) == [] + + +# ------------------------------------------------------------- 적립 이행 + +def test_contribution_plan_absent_is_not_flagged(): + assert summarize_contribution_plan( + "b", None, None, date(2026, 1, 1), date(2026, 8, 26))["verdict"] == "OK" + assert summarize_contribution_plan( + "b", {"enabled": False}, None, date(2026, 1, 1), date(2026, 8, 26), + )["verdict"] == "OK" + + +def test_missing_deposit_is_flagged_after_grace(): + """입금이 한 번도 없고 개시 후 주기+유예를 넘기면 ATTENTION.""" + plan = {"enabled": True, "cadence": "monthly", "amount": 100000} + r = summarize_contribution_plan("kr_pocket", plan, None, + date(2026, 7, 10), date(2026, 8, 26)) + assert r["verdict"] == "ATTENTION" + assert "미실행" in r["note"] + assert r["days_since"] == 47 + + +def test_new_track_within_first_period_is_not_flagged(): + """개시 직후에는 아직 적립 시점이 오지 않았으므로 경보하지 않는다.""" + plan = {"enabled": True, "cadence": "monthly", "amount": 100000} + r = summarize_contribution_plan("kr_pocket", plan, None, + date(2026, 8, 1), date(2026, 8, 26)) + assert r["verdict"] == "OK" + + +def test_stale_deposit_is_flagged(): + plan = {"enabled": True, "cadence": "monthly", "amount": 100000} + r = summarize_contribution_plan("kr_pocket", plan, datetime(2026, 6, 1), + date(2026, 5, 1), date(2026, 8, 26)) + assert r["verdict"] == "ATTENTION" + assert "지연" in r["note"] + + +def test_recent_deposit_is_ok(): + plan = {"enabled": True, "cadence": "monthly", "amount": 100000} + r = summarize_contribution_plan("kr_pocket", plan, datetime(2026, 8, 1), + date(2026, 5, 1), date(2026, 8, 26)) + assert r["verdict"] == "OK" + + +# --------------------------------------------------------- 운영 설정 불변식 + +def test_enabled_baskets_declare_deployment_band(): + """집계 배치율 밴드가 빠지면 현금 래칫이 조용히 되살아난다.""" + for name, cfg in BasketRebalancer._load_baskets_config().items(): + if not cfg.get("enabled"): + continue + band = (cfg.get("rebalance") or {}).get("deployment_band") + assert band is not None, "%s: deployment_band 미선언" % name + assert 0 < float(band) < 0.5 + + +def test_deployment_monitoring_is_not_disabled(): + """tolerance 1.0은 감시 해제다 — 배치율 누수를 헬스가 못 본다.""" + for name, cfg in BasketRebalancer._load_baskets_config().items(): + if not cfg.get("enabled"): + continue + tol = (cfg.get("monitoring") or {}).get("deployment_tolerance", 0.05) + assert float(tol) < 1.0, ( + "%s: 배치율 감시가 사실상 꺼져 있다(tolerance=%s)" % (name, tol) + ) diff --git a/tests/test_executor_state_machine.py b/tests/test_executor_state_machine.py index 589185a6..670dbf6f 100644 --- a/tests/test_executor_state_machine.py +++ b/tests/test_executor_state_machine.py @@ -215,6 +215,15 @@ def _prepare_live_executor(self, executor, kis_api): executor.mode = "live" executor.live_gate_validated = True executor.kis_api = kis_api + # 이 클래스가 검증하는 건 주문 상태기계(ACK/체결확인/reconcile)이지 실계좌 + # kill switch가 아니다. 실계좌 판정을 인스턴스 단위로 못박아 그 분기를 + # 결정론적으로 비켜 간다(공유 Config 싱글톤은 건드리지 않는다). + # + # 종전에는 이걸 명시하지 않아 로컬 config/settings.yaml(git 미추적)의 + # use_mock: true에 얹혀 통과했다. CI에는 그 파일이 없어 _is_real_money_live()가 + # True가 되고 ENABLE_LIVE_TRADING kill switch에 9건이 막혔다 — 즉 이 테스트들은 + # CI에서 한 번도 실제로 검증된 적이 없었다. 환경에 기대지 않고 스스로 선언한다. + executor._is_real_money_live = lambda: False executor.trading_hours = SimpleNamespace( can_place_order=lambda *a, **kw: {"allowed": True, "reason": ""} ) @@ -403,6 +412,7 @@ def sell_order(self, symbol, quantity, price): trailing_stop_price=68_000, strategy="scoring", account_key="test_sm", + mode="live", ) kis_api = UnfilledLookupFailedKIS() executor = self._prepare_live_executor(self._make_executor(), kis_api) @@ -418,7 +428,7 @@ def sell_order(self, symbol, quantity, price): assert "미체결 조회" in result["reason"] assert result["live_unfilled_check"]["checked"] is False assert kis_api.sell_called is False - position = get_position("000660", account_key="test_sm") + position = get_position("000660", account_key="test_sm", mode="live") assert position is not None assert position.quantity == 5 assert not OrderGuard.has_pending("000660") @@ -707,7 +717,7 @@ class AckNoFillKIS: def has_unfilled_orders(self, symbol): return False - def sell_order(self, symbol, quantity, price): + def sell_order(self, symbol, quantity, price, order_type="00"): return {"odno": "S123"} def get_filled_avg_price_after_order(self, symbol, order_output): @@ -723,6 +733,7 @@ def get_filled_avg_price_after_order(self, symbol, order_output): trailing_stop_price=68_000, strategy="scoring", account_key="test_sm", + mode="live", ) executor = self._prepare_live_executor(self._make_executor(), AckNoFillKIS()) @@ -737,7 +748,7 @@ def get_filled_avg_price_after_order(self, symbol, order_output): assert result["order_pending"] is True assert result["requires_reconcile"] is True assert result["order_status"] == OrderStatus.ACKED.value - position = get_position("000660", account_key="test_sm") + position = get_position("000660", account_key="test_sm", mode="live") assert position is not None assert position.quantity == 5 orders = [o for o in executor.order_book._orders.values() if o.symbol == "000660"] @@ -1005,7 +1016,7 @@ def get_order_execution_after_order(self, symbol, order_output): assert result["success"] is True, result.get("reason") assert result["quantity"] == 4 assert kis.last_qty == 4 # 사이저가 덮어쓰지 않고 고정수량 그대로 주문 - pos = get_position("005933", account_key="test_sm") + pos = get_position("005933", account_key="test_sm", mode="live") assert pos is not None and pos.quantity == 4 orders = [o for o in executor.order_book._orders.values() if o.symbol == "005933"] assert orders[-1].status == OrderStatus.FILLED diff --git a/tests/test_kis_cancel_safety.py b/tests/test_kis_cancel_safety.py new file mode 100644 index 00000000..52e42b2d --- /dev/null +++ b/tests/test_kis_cancel_safety.py @@ -0,0 +1,188 @@ +"""KIS 국내주식 취소 조회·제출 경계의 fail-closed 회귀 테스트.""" + +from unittest.mock import patch + +import pytest + +from api.kis_api import KISApi, authorized_kis_order_submission + + +def _bare_api(*, use_mock=False): + api = object.__new__(KISApi) + api.use_mock = use_mock + api.base_url = ( + "https://openapivts.koreainvestment.com:29443" + if use_mock + else "https://openapi.koreainvestment.com:9443" + ) + api.cano = "12345678" + api.acnt_prdt_cd = "01" + api._is_configured = lambda: True + return api + + +def _cancelable_row(**overrides): + row = { + "pdno": "005930", + "sll_buy_dvsn_cd": "02", + "odno": "0000001234", + "ord_gno_brno": "06010", + "psbl_qty": "3", + "tot_ccld_qty": "0", + "ord_qty": "3", + "ord_unpr": "70000", + "ord_dvsn_cd": "00", + "excg_id_dvsn_cd": "KRX", + "ord_tmd": "101500", + } + row.update(overrides) + return row + + +def test_cancelable_buy_query_preserves_required_cancel_fields(): + api = _bare_api() + captured = {} + + def fake_request(method, path, tr_id, params=None, **kwargs): + captured.update({ + "method": method, + "path": path, + "tr_id": tr_id, + "params": params, + }) + return {"rt_cd": "0", "output": [_cancelable_row()]} + + api._request = fake_request + result = api.get_cancelable_order_status("005930", "BUY") + + assert result["checked"] is True + assert result["has_cancelable"] is True + assert result["orders"] == [ + { + "symbol": "005930", + "side": "BUY", + "order_no": "0000001234", + "order_branch": "06010", + "cancelable_qty": 3, + "filled_qty": 0, + "ordered_qty": 3, + "order_price": "70000", + "order_type": "00", + "exchange_id": "KRX", + "order_time": "101500", + } + ] + assert captured == { + "method": "GET", + "path": "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl", + "tr_id": "TTTC0084R", + "params": { + "CANO": "12345678", + "ACNT_PRDT_CD": "01", + "INQR_DVSN_1": "1", + "INQR_DVSN_2": "2", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + } + + +@pytest.mark.parametrize( + "bad_row, expected_reason", + [ + (_cancelable_row(psbl_qty="NaN"), "psbl_qty_invalid"), + (_cancelable_row(tot_ccld_qty=""), "tot_ccld_qty_invalid"), + (_cancelable_row(ord_gno_brno=""), "cancel_fields_missing"), + (_cancelable_row(excg_id_dvsn_cd=""), "cancel_fields_missing"), + ], +) +def test_cancelable_query_fails_closed_on_malformed_order(bad_row, expected_reason): + api = _bare_api() + api._request = lambda *args, **kwargs: {"rt_cd": "0", "output": [bad_row]} + + result = api.get_cancelable_order_status("005930", "BUY") + + assert result["checked"] is False + assert result["reason"] == "kis_cancelable_order_malformed" + assert result["malformed"][0]["reason"] == expected_reason + + +def test_cancelable_query_fails_closed_at_unpageable_fifty_row_boundary(): + api = _bare_api() + api._request = lambda *args, **kwargs: { + "rt_cd": "0", + "output": [_cancelable_row(odno=f"{index:010d}") for index in range(50)], + } + + result = api.get_cancelable_order_status("005930", "BUY") + + assert result["checked"] is False + assert result["reason"] == "kis_cancelable_pagination_required" + assert result["returned_count"] == 50 + + +def test_real_cancel_requires_executor_capability_and_uses_official_payload(): + api = _bare_api(use_mock=False) + captured = {} + + def fake_request(method, path, tr_id, body=None, **kwargs): + captured.update({ + "method": method, + "path": path, + "tr_id": tr_id, + "body": body, + "idempotent": kwargs.get("idempotent"), + }) + return {"rt_cd": "0", "output": {"odno": "CANCEL-1"}} + + api._request = fake_request + + with pytest.raises(PermissionError): + api.cancel_order("0000001234", "06010", 3, "00", "KRX") + + with authorized_kis_order_submission(): + result = api.cancel_order("0000001234", "06010", 3, "00", "krx") + + assert result == {"odno": "CANCEL-1"} + assert captured == { + "method": "POST", + "path": "/uapi/domestic-stock/v1/trading/order-rvsecncl", + "tr_id": "TTTC0013U", + "body": { + "CANO": "12345678", + "ACNT_PRDT_CD": "01", + "KRX_FWDG_ORD_ORGNO": "06010", + "ORGN_ODNO": "0000001234", + "ORD_DVSN": "00", + "RVSE_CNCL_DVSN_CD": "02", + "ORD_QTY": "3", + "ORD_UNPR": "0", + "QTY_ALL_ORD_YN": "N", + "EXCG_ID_DVSN_CD": "KRX", + }, + "idempotent": False, + } + + +@pytest.mark.parametrize("quantity", [0, -1, 1.5, float("nan"), float("inf")]) +def test_cancel_rejects_invalid_quantity_before_request(quantity): + api = _bare_api(use_mock=True) + api._request = lambda *args, **kwargs: pytest.fail("invalid cancel reached API") + + with pytest.raises(ValueError): + api.cancel_order("0000001234", "06010", quantity, "00", "KRX") + + +def test_cancel_response_loss_is_not_retried_by_low_level_request(): + """취소도 주문과 동일한 비멱등 경계로 제출되는지 직접 확인한다.""" + api = _bare_api(use_mock=True) + api._requires_order_capability = lambda: False + api._request = lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("sentinel") + ) + + with patch.object(api, "_request", side_effect=RuntimeError("sentinel")) as request: + with pytest.raises(RuntimeError, match="sentinel"): + api.cancel_order("0000001234", "06010", 3, "00", "KRX") + + assert request.call_args.kwargs["idempotent"] is False diff --git a/tests/test_kis_order_idempotency.py b/tests/test_kis_order_idempotency.py index 28f09169..a5096f15 100644 --- a/tests/test_kis_order_idempotency.py +++ b/tests/test_kis_order_idempotency.py @@ -1,8 +1,9 @@ """KIS _request 비멱등(주문) 재시도 안전성 회귀 테스트. -핵심: 주문 제출(POST)은 응답을 못 받은 네트워크 오류에서 재전송하면 이중 체결이 -난다. idempotent=False면 한 번만 보내고, 체결 여부 불명 예외(KISOrderResponseUnknown)를 -던져 상위 재시도 래퍼가 재전송 대신 reconcile 경로로 분기하게 한다. +핵심: 주문 제출(POST)은 네트워크 응답 유실뿐 아니라 HTTP 429/5xx에서도 +브로커가 요청을 처리했는지 단정할 수 없다. idempotent=False면 한 번만 보내고, +체결 여부 불명 예외(KISOrderResponseUnknown)를 던져 상위 재시도 래퍼가 +재전송 대신 reconcile 경로로 분기하게 한다. """ import time from unittest.mock import patch @@ -82,6 +83,37 @@ def fake_post(*a, **kw): assert calls["post"] == 1 +@pytest.mark.parametrize("status_code", [429, 500, 502, 503, 504]) +def test_order_post_not_resubmitted_on_ambiguous_http_status(status_code): + """비멱등 주문은 429/5xx 응답을 UNKNOWN으로 올리고 단 1회만 POST한다.""" + api = _make_api() + calls = {"post": 0} + + class FakeResp: + headers = {"Retry-After": "1"} + + def __init__(self, code): + self.status_code = code + + def fake_post(*a, **kw): + calls["post"] += 1 + return FakeResp(status_code) + + with patch("api.kis_api.requests.post", side_effect=fake_post), \ + patch("api.kis_api.time.sleep", side_effect=AssertionError("order must not retry")): + with pytest.raises(KISOrderResponseUnknown, match=f"HTTP {status_code}"): + api._request( + "POST", + "/order", + "TR", + body={"x": 1}, + max_retries=3, + idempotent=False, + ) + + assert calls["post"] == 1 + + def test_idempotent_get_still_retries_on_timeout(): """idempotent=True(기본): GET 조회는 기존처럼 재시도한다.""" api = _make_api(max_retry=3) diff --git a/tests/test_ledger_mode_isolation.py b/tests/test_ledger_mode_isolation.py new file mode 100644 index 00000000..43e51f6e --- /dev/null +++ b/tests/test_ledger_mode_isolation.py @@ -0,0 +1,153 @@ +"""Paper/live 장부가 계좌 키·종목·날짜가 같아도 서로 간섭하지 않는다.""" + +from datetime import datetime, timedelta + +import pytest + +from database.models import Base, Position, PortfolioSnapshot, init_database +from database.repositories import ( + delete_position, + get_all_positions, + get_cash_flow_total, + get_cash_flow_total_between, + get_latest_peak_value, + get_latest_snapshot_summary, + get_portfolio_snapshots_between, + get_position, + has_cash_flows, + record_cash_flow, + reduce_position, + save_portfolio_snapshot, + save_position, + update_position_targets, +) + + +def _unique_columns(table): + return { + tuple(column.name for column in constraint.columns) + for constraint in table.constraints + if constraint.__class__.__name__ == "UniqueConstraint" + } + + +def test_models_require_mode_and_scope_uniques_by_mode(): + assert Position.__table__.c.mode.nullable is False + assert PortfolioSnapshot.__table__.c.mode.nullable is False + assert Position.__table__.c.mode.server_default.arg == "paper" + assert PortfolioSnapshot.__table__.c.mode.server_default.arg == "paper" + assert ("mode", "account_key", "symbol") in _unique_columns(Position.__table__) + assert ("mode", "account_key", "date") in _unique_columns( + PortfolioSnapshot.__table__ + ) + # Base 전체 메타데이터에서도 동일 계약이 유지된다. + assert Base.metadata.tables["positions"] is Position.__table__ + + +def test_position_crud_isolated_by_mode(): + init_database() + account_key = "mode_isolation:positions" + symbol = "MODE-POS" + + save_position(symbol, 100.0, 2, account_key=account_key) # default paper + save_position(symbol, 200.0, 5, account_key=account_key, mode="live") + save_position(symbol, 300.0, 1, account_key=account_key, mode="LIVE") + + paper = get_position(symbol, account_key=account_key) + live = get_position(symbol, account_key=account_key, mode="live") + assert (paper.quantity, paper.avg_price) == (2, pytest.approx(100.0)) + assert live.quantity == 6 + assert live.avg_price == pytest.approx((200 * 5 + 300) / 6) + assert {row.mode for row in get_all_positions(account_key=account_key)} == {"paper"} + assert {row.mode for row in get_all_positions(account_key=account_key, mode="live")} == {"live"} + + update_position_targets( + symbol, + stop_loss_price=150.0, + account_key=account_key, + mode="live", + ) + assert get_position(symbol, account_key=account_key).stop_loss_price is None + assert get_position(symbol, account_key=account_key, mode="live").stop_loss_price == 150.0 + + reduce_position(symbol, 2, account_key=account_key, mode="live") + assert get_position(symbol, account_key=account_key, mode="live").quantity == 4 + assert get_position(symbol, account_key=account_key).quantity == 2 + + delete_position(symbol, account_key=account_key) # default paper only + assert get_position(symbol, account_key=account_key) is None + assert get_position(symbol, account_key=account_key, mode="live") is not None + + +def test_snapshot_upsert_peak_and_ranges_are_isolated_by_mode(): + init_database() + account_key = "mode_isolation:snapshots" + day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + assert save_portfolio_snapshot( + 1_000.0, + 600.0, + 400.0, + cumulative_return=1.0, + peak_value=1_100.0, + account_key=account_key, + snapshot_date=day, + ) + assert save_portfolio_snapshot( + 9_000.0, + 5_000.0, + 4_000.0, + cumulative_return=9.0, + peak_value=9_900.0, + account_key=account_key, + snapshot_date=day, + mode="live", + ) + # paper upsert는 같은 날 live 행을 갱신하지 않는다. + assert save_portfolio_snapshot( + 1_200.0, + 700.0, + 500.0, + cumulative_return=2.0, + peak_value=1_250.0, + account_key=account_key, + snapshot_date=day, + ) + + assert get_latest_peak_value(account_key) == pytest.approx(1_250.0) + assert get_latest_peak_value(account_key, mode="live") == pytest.approx(9_900.0) + assert get_latest_snapshot_summary(account_key)["total_value"] == pytest.approx(1_200.0) + assert get_latest_snapshot_summary(account_key, mode="live")["total_value"] == pytest.approx(9_000.0) + + start = day - timedelta(days=1) + end = day + timedelta(days=1) + paper = get_portfolio_snapshots_between(start, end, account_key=account_key) + live = get_portfolio_snapshots_between( + start, end, account_key=account_key, mode="live" + ) + assert [row["total_value"] for row in paper] == [pytest.approx(1_200.0)] + assert [row["total_value"] for row in live] == [pytest.approx(9_000.0)] + + +def test_cash_flow_totals_and_ranges_are_isolated_by_mode(): + init_database() + account_key = "mode_isolation:cash_flows" + t0 = datetime.now().replace(microsecond=0) - timedelta(hours=2) + t1 = t0 + timedelta(hours=1) + + record_cash_flow(100.0, account_key=account_key, occurred_at=t0) + record_cash_flow(25.0, account_key=account_key, occurred_at=t1) + record_cash_flow(900.0, account_key=account_key, occurred_at=t0, mode="live") + record_cash_flow(-100.0, account_key=account_key, occurred_at=t1, mode="LIVE") + + assert get_cash_flow_total(account_key) == pytest.approx(125.0) + assert get_cash_flow_total(account_key, mode="live") == pytest.approx(800.0) + assert get_cash_flow_total_between( + account_key, after=t0, until=t1 + ) == pytest.approx(25.0) + assert get_cash_flow_total_between( + account_key, after=t0, until=t1, mode="live" + ) == pytest.approx(-100.0) + assert has_cash_flows(account_key) is True + assert has_cash_flows(account_key, mode="live") is True + assert has_cash_flows(account_key, mode="legacy") is False diff --git a/tests/test_live_gate.py b/tests/test_live_gate.py index a12d4994..06fa3e43 100644 --- a/tests/test_live_gate.py +++ b/tests/test_live_gate.py @@ -4,6 +4,7 @@ from core.live_gate import ( LIVE_GATE_ARTIFACT_TYPE, LIVE_GATE_SCHEMA_VERSION, + get_current_git_worktree_state, validate_canonical_metadata_integrity, validate_live_readiness, ) @@ -14,6 +15,48 @@ class DummyConfig: resolved_hash = "resolved-ok" +def test_git_worktree_state_checks_tracked_and_untracked_changes(monkeypatch): + calls = [] + + def fake_check_output(command, **kwargs): + calls.append((command, kwargs)) + return " M core/live_gate.py\n?? untracked.txt\n" + + monkeypatch.setattr("core.live_gate.subprocess.check_output", fake_check_output) + + clean, detail = get_current_git_worktree_state() + + assert clean is False + assert detail.splitlines() == [" M core/live_gate.py", "?? untracked.txt"] + assert calls[0][0] == [ + "git", + "status", + "--porcelain", + "--untracked-files=normal", + ] + + +def test_git_worktree_state_is_fail_closed_on_git_error(monkeypatch): + def fail(*args, **kwargs): + raise OSError("git unavailable") + + monkeypatch.setattr("core.live_gate.subprocess.check_output", fail) + + clean, detail = get_current_git_worktree_state() + + assert clean is None + assert "git unavailable" in detail + + +def test_git_worktree_state_reports_clean(monkeypatch): + monkeypatch.setattr( + "core.live_gate.subprocess.check_output", + lambda *args, **kwargs: "", + ) + + assert get_current_git_worktree_state() == (True, "") + + def _write_json(path, payload): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") @@ -772,12 +815,52 @@ def test_valid_canonical_bundle_and_paper_evidence_pass(tmp_path): promotion_dir=promotion_dir, evidence_dir=evidence_dir, current_git_hash="abc123", + current_git_worktree_state=(True, ""), now=datetime(2026, 4, 29, 12, 0, 0), ) assert issues == [] +def test_dirty_worktree_blocks_live_gate(tmp_path): + promotion_dir = tmp_path / "reports" / "promotion" + evidence_dir = tmp_path / "reports" / "paper_evidence" + _write_bundle(promotion_dir) + _write_evidence(evidence_dir) + + issues = validate_live_readiness( + DummyConfig(), + "scoring", + promotion_dir=promotion_dir, + evidence_dir=evidence_dir, + current_git_hash="abc123", + current_git_worktree_state=(False, " M tracked.py\n?? untracked.txt"), + now=datetime(2026, 4, 29, 12, 0, 0), + ) + + assert any("worktree가 clean 상태가 아님" in issue for issue in issues) + assert any("tracked/untracked 변경 2건" in issue for issue in issues) + + +def test_worktree_inspection_error_blocks_live_gate(tmp_path): + promotion_dir = tmp_path / "reports" / "promotion" + evidence_dir = tmp_path / "reports" / "paper_evidence" + _write_bundle(promotion_dir) + _write_evidence(evidence_dir) + + issues = validate_live_readiness( + DummyConfig(), + "scoring", + promotion_dir=promotion_dir, + evidence_dir=evidence_dir, + current_git_hash="abc123", + current_git_worktree_state=(None, "git unavailable"), + now=datetime(2026, 4, 29, 12, 0, 0), + ) + + assert any("git worktree 상태 확인 실패" in issue for issue in issues) + + def test_current_blockers_no_go_blocks_live_gate(tmp_path): promotion_dir = tmp_path / "reports" / "promotion" evidence_dir = tmp_path / "reports" / "paper_evidence" @@ -1066,6 +1149,7 @@ def test_target_weight_live_gate_accepts_verified_pilot_evidence(tmp_path): promotion_dir=promotion_dir, evidence_dir=evidence_dir, current_git_hash="abc123", + current_git_worktree_state=(True, ""), now=datetime(2026, 4, 29, 12, 0, 0), ) diff --git a/tests/test_live_status_sync.py b/tests/test_live_status_sync.py index 9ec24473..24860001 100644 --- a/tests/test_live_status_sync.py +++ b/tests/test_live_status_sync.py @@ -15,6 +15,10 @@ def enforce_live_auto_entry_policy(self): # 실제 Config 인터페이스 반영 — live 진입 시 ENV auto_entry 켜기 무시 정책(no-op 더블) pass + def get_account_no(self, strategy=""): + assert strategy == "scoring" + return "12345678-01" + def test_run_live_trading_blocks_when_registry_disallows_live(monkeypatch): import main as main_mod @@ -61,6 +65,9 @@ def test_run_live_trading_starts_after_registry_and_canonical_gate_pass(monkeypa ) class DummyKIS: + def __init__(self, account_no=None): + assert account_no == "12345678-01" + def authenticate(self): return True @@ -140,6 +147,9 @@ def test_run_live_trading_blocks_when_kis_connection_check_fails(monkeypatch): ) class DummyKIS: + def __init__(self, account_no=None): + assert account_no == "12345678-01" + def authenticate(self): return True @@ -184,6 +194,9 @@ def test_run_live_trading_blocks_when_initial_broker_sync_fails(monkeypatch): ) class DummyKIS: + def __init__(self, account_no=None): + assert account_no == "12345678-01" + def authenticate(self): return True diff --git a/tests/test_order_executor_paper.py b/tests/test_order_executor_paper.py index 63805519..c5462b8a 100644 --- a/tests/test_order_executor_paper.py +++ b/tests/test_order_executor_paper.py @@ -35,6 +35,21 @@ def _blocked_runtime_state(): ) +def _allow_fixed_quantity_strategy_filters(monkeypatch, executor): + """다른 주문 계약 테스트에서는 시점 의존 진입 필터를 고정한다.""" + executor.config.risk_params.setdefault("gap_risk", {})["enabled"] = False + executor.config.trading["skip_earnings_days"] = 0 + monkeypatch.setattr( + executor.risk_manager, + "check_correlation_risk", + lambda *args, **kwargs: { + "scale": 1.0, + "high_corr_symbols": [], + "reason": "", + }, + ) + + def test_order_executor_import(): """OrderExecutor 임포트 및 paper 모드 초기화 가능""" from core.order_executor import OrderExecutor @@ -82,6 +97,7 @@ def test_execute_buy_quantity_records_exact_paper_quantity(fresh_db, monkeypatch from database.repositories import get_position, get_daily_trade_summary executor = OrderExecutor(account_key="exact_qty_test") + _allow_fixed_quantity_strategy_filters(monkeypatch, executor) monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) monkeypatch.setattr("core.paper_preflight.load_preflight_status", lambda strategy, strict=False: _passing_preflight()) monkeypatch.setattr("core.paper_runtime.get_paper_runtime_state", lambda *a, **kw: _normal_runtime_state()) @@ -638,6 +654,7 @@ def test_paper_buy_quantity_basket_strategy_exempt_from_preflight(fresh_db, monk from database.repositories import get_position executor = OrderExecutor(account_key="basket_exempt_test") + _allow_fixed_quantity_strategy_filters(monkeypatch, executor) monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) # preflight 산출물 없음 + runtime 조회도 실패하는 환경 (바스켓은 상태머신 밖) monkeypatch.setattr("core.paper_preflight.load_preflight_status", lambda strategy, strict=False: None) @@ -702,6 +719,7 @@ def test_paper_buy_quantity_allows_pilot_override(fresh_db, monkeypatch): from database.repositories import get_position executor = OrderExecutor(account_key="pilot_override_test") + _allow_fixed_quantity_strategy_filters(monkeypatch, executor) monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) monkeypatch.setattr( "core.paper_preflight.load_preflight_status", @@ -865,6 +883,7 @@ def test_monthly_buy_cap_is_scoped_by_account_mode_and_symbol(fresh_db, monkeypa _seed_buy_trades("scoped_cap_test", "005930", "live", 2) executor = OrderExecutor(account_key="scoped_cap_test") + _allow_fixed_quantity_strategy_filters(monkeypatch, executor) _set_monthly_buy_cap(executor, 2) monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) _allow_paper_entry(monkeypatch) @@ -1026,7 +1045,7 @@ def get_portfolio_summary(self): monkeypatch.setattr("core.portfolio_manager.PortfolioManager", FakePortfolioManager) monkeypatch.setattr( "database.repositories.get_portfolio_snapshots", - lambda days=30, account_key=None: pd.DataFrame( + lambda days=30, account_key=None, mode="paper": pd.DataFrame( [ { "date": datetime.now() - timedelta(days=1), @@ -1145,6 +1164,40 @@ def test_execute_buy_blocks_when_market_regime_disallows_new_buys(fresh_db, monk assert "시장 국면" in result["reason"] +def test_execute_buy_blocks_when_market_regime_module_raises(fresh_db, monkeypatch): + """필터 활성 상태에서 국면 모듈 예외가 나도 BUY를 허용하지 않는다.""" + from core.order_executor import OrderExecutor + + executor = OrderExecutor(account_key="market_regime_exception_test") + executor.config.trading["market_regime_filter"] = True + executor.config.trading["skip_earnings_days"] = 0 + executor.config.risk_params["gap_risk"]["enabled"] = False + monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) + + def raise_regime_error(config): + raise RuntimeError("regime dependency unavailable") + + monkeypatch.setattr( + "core.market_regime.get_regime_adjusted_params", + raise_regime_error, + ) + + result = executor.execute_buy( + symbol="005930", + price=60_000, + capital=10_000_000, + available_cash=10_000_000, + reason="market regime exception fail closed test", + strategy="scoring", + avg_daily_volume=1_000_000, + ) + + assert result["success"] is False + assert result["market_regime_blocked"] is True + assert result["market_regime"] == "unknown" + assert result["market_regime_details"]["fail_closed"] is True + + def test_paper_sell_ignores_drawdown_guard(fresh_db, monkeypatch): """손실 한도에 걸려도 기존 포지션 청산 SELL은 계속 허용한다.""" from core.order_executor import OrderExecutor @@ -1688,6 +1741,7 @@ def test_buy_position_save_failure_compensates_trade(fresh_db, monkeypatch): from database.models import get_session, TradeHistory executor = OrderExecutor(account_key="ledger_atomicity_test") + _allow_fixed_quantity_strategy_filters(monkeypatch, executor) monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) monkeypatch.setattr( "core.paper_preflight.load_preflight_status", @@ -1706,7 +1760,7 @@ def _fail_position(*a, **kw): with pytest.raises(RuntimeError, match="포지션 저장 실패 주입"): executor.execute_buy_quantity( symbol="069500", price=123_710, quantity=1, - capital=300_000, available_cash=300_000, + capital=1_000_000, available_cash=1_000_000, reason="보상 롤백 테스트", strategy="ledger_test", avg_daily_volume=1_000_000, ) diff --git a/tests/test_order_safety_hardening.py b/tests/test_order_safety_hardening.py new file mode 100644 index 00000000..dbb52aa7 --- /dev/null +++ b/tests/test_order_safety_hardening.py @@ -0,0 +1,970 @@ +"""실전 주문 경계 안전성 회귀 테스트. + +이 파일은 위험한 입력이 브로커 호출이나 로컬 장부 변경에 도달하지 +않는지, 그리고 실전 노출이 불명확해진 즉시 global HALT가 영속화 +시도되는지를 검증한다. tests/conftest.py가 먼저 QUANT_DB_PATH를 임시 +DB로 고정하며, 아래 autouse fixture는 운영 DB로의 회귀를 한 번 더 차단한다. +""" + +from datetime import datetime, timedelta +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from api.kis_api import KISApi, authorized_kis_order_submission +from core.order_executor import OrderExecutor +from core.order_state import OrderBook, OrderRecord, OrderStatus +from core.risk_manager import RiskManager + + +@pytest.fixture(autouse=True) +def _refuse_production_database_for_safety_tests(): + """주문 테스트가 운영 장부를 만지지 않도록 목적지를 확인한다.""" + configured = os.environ.get("QUANT_DB_PATH") + assert configured, "tests/conftest.py must isolate QUANT_DB_PATH" + assert Path(configured).resolve() != Path("data/quant_trader.db").resolve() + + +class _ForbiddenBroker: + def __init__(self): + self.calls = [] + + def buy_order(self, *args, **kwargs): + self.calls.append(("BUY", args, kwargs)) + raise AssertionError("invalid input reached the broker BUY boundary") + + def sell_order(self, *args, **kwargs): + self.calls.append(("SELL", args, kwargs)) + raise AssertionError("invalid input reached the broker SELL boundary") + + +def _minimal_risk_config( + *, + max_position_ratio=0.20, + max_investment_ratio=0.70, + min_cash_ratio=0.20, + max_risk_per_trade=0.01, + stop_loss_rate=0.03, + use_mock=True, +): + risk_params = { + "position_sizing": {"max_risk_per_trade": max_risk_per_trade}, + "diversification": { + "max_positions": 10, + "max_position_ratio": max_position_ratio, + "max_investment_ratio": max_investment_ratio, + "min_cash_ratio": min_cash_ratio, + }, + "stop_loss": {"type": "fixed", "fixed_rate": stop_loss_rate}, + "transaction_costs": { + "commission_rate": 0.0, + "tax_rate": 0.0, + "slippage": 0.0, + "slippage_ticks": 0, + "dynamic_slippage": {"enabled": False}, + }, + } + return SimpleNamespace( + trading={ + "pending_order_ttl_seconds": 600, + "ledger_reconcile_guard_ttl_seconds": 86_400, + }, + risk_params=risk_params, + kis_api={"use_mock": use_mock}, + ) + + +def _executor_without_external_initialization(*, config=None, mode="paper"): + """KIS 인증과 영속 주문 복구 없이 단일 경계만 검증하는 executor.""" + config = config or _minimal_risk_config() + executor = object.__new__(OrderExecutor) + executor.config = config + executor.account_key = "safety_hardening" + executor.mode = mode + executor.live_gate_validated = mode == "live" + executor.risk_manager = RiskManager(config) + executor.kis_api = _ForbiddenBroker() + executor.order_book = OrderBook() + executor._global_trading_halt_check = lambda *args, **kwargs: { + "allowed": True, + "reason": "", + } + executor._live_buy_gate_check = lambda *args, **kwargs: { + "allowed": True, + "reason": "", + } + executor._pre_order_check = lambda *args, **kwargs: { + "allowed": True, + "reason": "", + } + executor._should_block_new_buy_volatility_window = lambda: False + return executor + + +def _forbid_ledger_and_exposure_access(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("invalid input reached broker/ledger/exposure boundary") + + monkeypatch.setattr("core.order_executor.save_trade", forbidden) + monkeypatch.setattr("core.order_executor.save_position", forbidden) + monkeypatch.setattr("core.order_executor.get_all_positions", forbidden) + + +@pytest.mark.parametrize("bad_value", [float("nan"), float("inf"), float("-inf")]) +@pytest.mark.parametrize("bad_field", ["capital", "available_cash"]) +@pytest.mark.parametrize("fixed_quantity", [False, True], ids=["sized-buy", "fixed-buy"]) +def test_buy_paths_reject_nonfinite_funds_before_broker_or_ledger( + monkeypatch, + bad_value, + bad_field, + fixed_quantity, +): + """NaN/Inf 자금은 리스크 계산, 장부, 브로커 전에 fail-closed 차단된다.""" + _forbid_ledger_and_exposure_access(monkeypatch) + executor = _executor_without_external_initialization() + kwargs = { + "symbol": "005930", + "price": 60_000, + "capital": 10_000_000, + "available_cash": 10_000_000, + "reason": "nonfinite guard regression", + "strategy": "safety_test", + } + kwargs[bad_field] = bad_value + + if fixed_quantity: + result = executor.execute_buy_quantity(quantity=1, **kwargs) + else: + result = executor.execute_buy(**kwargs) + + assert result["success"] is False + assert result["capital_invalid"] is True + assert executor.kis_api.calls == [] + + +@pytest.mark.parametrize( + ("positions", "quantity", "expected_fragment"), + [ + ([], 3, "단일 종목 비중"), + ( + [ + SimpleNamespace( + symbol="005930", + avg_price=100_000, + quantity=1, + total_invested=150_000, + ) + ], + 1, + "단일 종목 비중", + ), + ], + ids=["new-position-over-limit", "accumulated-position-over-limit"], +) +def test_fixed_quantity_buy_enforces_projected_single_symbol_cap( + monkeypatch, + positions, + quantity, + expected_fragment, +): + """신규 및 추가매수 모두 기존+신규 노출을 합산해 20% 상한을 검사한다.""" + executor = _executor_without_external_initialization() + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kwargs: positions) + + def forbidden(*args, **kwargs): + raise AssertionError("exposure-limit violation reached broker/ledger") + + monkeypatch.setattr("core.order_executor.save_trade", forbidden) + monkeypatch.setattr("core.order_executor.save_position", forbidden) + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=quantity, + capital=1_000_000, + available_cash=1_000_000, + reason="projected symbol exposure regression", + strategy="safety_test", + ) + + assert result["success"] is False + assert result["exposure_limit_blocked"] is True + assert expected_fragment in result["reason"] + assert executor.kis_api.calls == [] + + +def test_fixed_quantity_buy_cannot_exceed_one_trade_loss_budget(monkeypatch): + """목표비중 어댑터의 고정 수량도 1% 1회 손실 예산을 늘리지 못한다.""" + config = _minimal_risk_config( + max_position_ratio=0.50, + max_investment_ratio=0.90, + min_cash_ratio=0.0, + max_risk_per_trade=0.01, + stop_loss_rate=0.10, + ) + executor = _executor_without_external_initialization(config=config) + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kwargs: []) + + def forbidden(*args, **kwargs): + raise AssertionError("per-trade risk violation reached broker/ledger") + + monkeypatch.setattr("core.order_executor.save_trade", forbidden) + monkeypatch.setattr("core.order_executor.save_position", forbidden) + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=2, + capital=1_000_000, + available_cash=1_000_000, + reason="fixed risk budget regression", + strategy="safety_test", + ) + + assert result["success"] is False + assert result["per_trade_risk_blocked"] is True + assert result["projected_loss"] > result["risk_budget"] + assert executor.kis_api.calls == [] + + +def test_fixed_quantity_buy_applies_correlation_filter(monkeypatch): + """고정수량 어댑터도 기존 포지션과의 상관 리스크를 우회하지 않는다.""" + config = _minimal_risk_config( + max_position_ratio=0.50, + max_investment_ratio=0.90, + min_cash_ratio=0.0, + ) + executor = _executor_without_external_initialization(config=config) + position = SimpleNamespace( + symbol="000660", + avg_price=100_000, + quantity=1, + total_invested=100_000, + ) + monkeypatch.setattr( + "core.order_executor.get_all_positions", lambda **kwargs: [position] + ) + monkeypatch.setattr( + executor.risk_manager, + "check_correlation_risk", + lambda *args, **kwargs: { + "scale": 0.0, + "blocked": True, + "reason": "상관 데이터 확인 실패", + }, + ) + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=1, + capital=1_000_000, + available_cash=900_000, + strategy="safety_test", + ) + + assert result["success"] is False + assert result["correlation_risk_blocked"] is True + assert executor.kis_api.calls == [] + + +def test_fixed_quantity_buy_applies_gap_filter(monkeypatch): + executor = _executor_without_external_initialization() + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kwargs: []) + monkeypatch.setattr( + executor.risk_manager, + "check_correlation_risk", + lambda *args, **kwargs: {"scale": 1.0, "reason": ""}, + ) + executor._gap_up_entry_check = lambda *args, **kwargs: { + "allowed": False, + "reason": "갭업 추격매수 차단", + "gap_risk_blocked": True, + } + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=1, + capital=1_000_000, + available_cash=1_000_000, + strategy="safety_test", + ) + + assert result["success"] is False + assert result["gap_risk_blocked"] is True + + +def test_gap_filter_nan_threshold_blocks_before_price_lookup(monkeypatch): + config = _minimal_risk_config() + config.risk_params["gap_risk"] = { + "enabled": True, + "gap_up_entry_block": float("nan"), + } + executor = _executor_without_external_initialization(config=config) + monkeypatch.setattr( + "core.data_collector.DataCollector", + lambda: pytest.fail("invalid gap config must block before price lookup"), + ) + + result = executor._gap_up_entry_check("005930", 100_000) + + assert result["allowed"] is False + assert result["gap_risk_blocked"] is True + assert "설정 오류" in result["reason"] + + +def test_fixed_quantity_buy_applies_earnings_filter(monkeypatch): + config = _minimal_risk_config() + config.trading["skip_earnings_days"] = 3 + executor = _executor_without_external_initialization(config=config) + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kwargs: []) + monkeypatch.setattr( + executor.risk_manager, + "check_correlation_risk", + lambda *args, **kwargs: {"scale": 1.0, "reason": ""}, + ) + executor._gap_up_entry_check = lambda *args, **kwargs: { + "allowed": True, + "reason": "", + } + monkeypatch.setattr( + "core.earnings_filter.is_near_earnings", + lambda *args, **kwargs: (True, "실적 발표일 인접"), + ) + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=1, + capital=1_000_000, + available_cash=1_000_000, + strategy="safety_test", + ) + + assert result["success"] is False + assert result["earnings_filter_blocked"] is True + + +def test_fixed_quantity_buy_applies_performance_degradation_filter(monkeypatch): + config = _minimal_risk_config() + config.risk_params["performance_degradation"] = { + "enabled": True, + "recent_trades": 20, + "min_win_rate": 0.35, + } + executor = _executor_without_external_initialization(config=config) + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kwargs: []) + monkeypatch.setattr( + executor.risk_manager, + "check_correlation_risk", + lambda *args, **kwargs: {"scale": 1.0, "reason": ""}, + ) + monkeypatch.setattr( + executor.risk_manager, + "check_recent_performance", + lambda trades: {"allowed": False, "reason": "최근 성과 열화"}, + ) + monkeypatch.setattr( + "database.repositories.get_recent_sell_trades", lambda **kwargs: [] + ) + executor._gap_up_entry_check = lambda *args, **kwargs: { + "allowed": True, + "reason": "", + } + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=1, + capital=1_000_000, + available_cash=1_000_000, + strategy="safety_test", + ) + + assert result["success"] is False + assert result["performance_degradation_blocked"] is True + + +def test_fixed_quantity_buy_applies_market_regime_filter(monkeypatch): + config = _minimal_risk_config() + config.trading["market_regime_filter"] = True + executor = _executor_without_external_initialization(config=config) + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kwargs: []) + monkeypatch.setattr( + executor.risk_manager, + "check_correlation_risk", + lambda *args, **kwargs: {"scale": 1.0, "reason": ""}, + ) + monkeypatch.setattr( + "database.repositories.get_recent_sell_trades", lambda **kwargs: [] + ) + monkeypatch.setattr( + "core.market_regime.get_regime_adjusted_params", + lambda config: { + "allow_buys": False, + "regime": "bearish", + "position_scale": 0.0, + }, + ) + executor._gap_up_entry_check = lambda *args, **kwargs: { + "allowed": True, + "reason": "", + } + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=1, + capital=1_000_000, + available_cash=1_000_000, + strategy="safety_test", + ) + + assert result["success"] is False + assert result["market_regime_blocked"] is True + + +def test_fixed_quantity_buy_applies_sector_cap(monkeypatch): + config = _minimal_risk_config( + max_position_ratio=0.50, + max_investment_ratio=0.90, + min_cash_ratio=0.0, + ) + config.risk_params["diversification"].update( + {"max_sector_ratio": 0.20, "sector_map_strict": True} + ) + executor = _executor_without_external_initialization(config=config) + position = SimpleNamespace( + symbol="000660", + avg_price=150_000, + quantity=1, + total_invested=150_000, + ) + monkeypatch.setattr( + "core.order_executor.get_all_positions", lambda **kwargs: [position] + ) + executor._get_sector_map_cached = lambda: { + "000660": "반도체", + "005930": "반도체", + } + + result = executor.execute_buy_quantity( + symbol="005930", + price=100_000, + quantity=1, + capital=1_000_000, + available_cash=850_000, + strategy="safety_test", + ) + + assert result["success"] is False + assert result["exposure_limit_blocked"] is True + assert "업종" in result["reason"] + + +class _RecordingSellBroker: + def __init__(self): + self.calls = [] + + def get_cancelable_order_status(self, symbol, side="BUY"): + return { + "checked": True, + "reason": "ok", + "has_cancelable": False, + "orders": [], + } + + def cancel_order(self, *args, **kwargs): + raise AssertionError("취소할 BUY가 없는데 cancel_order가 호출됨") + + def sell_order(self, symbol, quantity, price, order_type="00"): + self.calls.append( + { + "symbol": symbol, + "quantity": quantity, + "price": price, + "order_type": order_type, + } + ) + return {"odno": "SELL-SAFETY-1"} + + +@pytest.mark.parametrize( + ("reason", "expected_price", "expected_order_type"), + [ + ("STOP_LOSS", 0, "01"), + ("긴급 전량 청산", 0, "01"), + ("TAKE_PROFIT", 71_000, "00"), + ], +) +def test_live_sell_uses_market_only_for_emergency_exit( + monkeypatch, + reason, + expected_price, + expected_order_type, +): + """손실 방어 청산은 시장가, 일반 익절은 지정가로 KIS에 제출된다.""" + config = _minimal_risk_config(use_mock=False) + executor = _executor_without_external_initialization(config=config, mode="live") + broker = _RecordingSellBroker() + executor.kis_api = broker + executor._get_min_holding_days = lambda: 0 + executor._pre_order_check = lambda **kwargs: {"allowed": True, "reason": ""} + executor._persistent_live_order_block = lambda *args, **kwargs: None + executor._live_unfilled_order_block = lambda *args, **kwargs: None + executor._claim_live_order_guard = lambda *args, **kwargs: None + executor._persist_order_record = lambda *args, **kwargs: None + executor._resolve_live_execution = lambda *args, **kwargs: { + "confirmed": False, + "reason": "live_fill_unconfirmed", + "filled_qty": 0, + "remaining_qty": 5, + } + executor._pending_live_execution_result = lambda **kwargs: { + "success": False, + "order_pending": True, + } + position = SimpleNamespace( + symbol="000660", + quantity=5, + avg_price=70_000, + bought_at=datetime.now() - timedelta(days=30), + ) + monkeypatch.setattr( + "core.order_executor.get_position", lambda *args, **kwargs: position + ) + + result = executor.execute_sell( + symbol="000660", + price=71_000, + reason=reason, + strategy="safety_test", + ) + + assert result["order_pending"] is True + assert broker.calls == [ + { + "symbol": "000660", + "quantity": 5, + "price": expected_price, + "order_type": expected_order_type, + } + ] + + +def _pending_buy(order_no="0000001234", *, filled_qty=0): + return { + "symbol": "000660", + "side": "BUY", + "order_no": order_no, + "order_branch": "06010", + "cancelable_qty": 2, + "filled_qty": filled_qty, + "ordered_qty": 2 + filled_qty, + "order_price": "70000", + "order_type": "00", + "exchange_id": "KRX", + "order_time": "101500", + } + + +class _EmergencyCancelBroker: + def __init__(self, statuses): + self.statuses = list(statuses) + self.events = [] + self._last_status = statuses[-1] + + def get_cancelable_order_status(self, symbol, side="BUY"): + self.events.append(("QUERY_CANCELABLE", symbol, side)) + if self.statuses: + self._last_status = self.statuses.pop(0) + return self._last_status + + def cancel_order(self, order_no, order_branch, quantity, order_type, exchange_id): + self.events.append( + ( + "CANCEL", + order_no, + order_branch, + quantity, + order_type, + exchange_id, + ) + ) + return {"odno": f"C-{order_no}"} + + def sell_order(self, symbol, quantity, price, order_type="00"): + self.events.append(("SELL", symbol, quantity, price, order_type)) + return {"odno": "SELL-AFTER-CANCEL"} + + +def _prepare_emergency_sell_executor(monkeypatch, broker): + config = _minimal_risk_config(use_mock=False) + executor = _executor_without_external_initialization(config=config, mode="live") + executor.kis_api = broker + executor._get_min_holding_days = lambda: 0 + executor._pre_order_check = lambda **kwargs: {"allowed": True, "reason": ""} + executor._persistent_live_order_block = lambda *args, **kwargs: None + executor._live_unfilled_order_block = lambda *args, **kwargs: None + executor._claim_live_order_guard = lambda *args, **kwargs: None + executor._persist_order_record = lambda *args, **kwargs: None + executor._mark_cancelled_live_buy_records = lambda *args, **kwargs: [] + executor._resolve_live_execution = lambda *args, **kwargs: { + "confirmed": False, + "reason": "live_fill_unconfirmed", + "filled_qty": 0, + "remaining_qty": 5, + } + executor._pending_live_execution_result = lambda **kwargs: { + "success": False, + "order_pending": True, + } + position = SimpleNamespace( + symbol="000660", + quantity=5, + avg_price=70_000, + bought_at=datetime.now() - timedelta(days=30), + ) + monkeypatch.setattr( + "core.order_executor.get_position", lambda *args, **kwargs: position + ) + return executor + + +def test_emergency_sell_cancels_conflicting_buy_before_market_sell(monkeypatch): + pending = _pending_buy() + broker = _EmergencyCancelBroker( + [ + {"checked": True, "reason": "ok", "orders": [pending]}, + {"checked": True, "reason": "ok", "orders": []}, + ] + ) + executor = _prepare_emergency_sell_executor(monkeypatch, broker) + + result = executor.execute_sell( + symbol="000660", + price=68_000, + reason="STOP_LOSS", + strategy="safety_test", + ) + + assert result["order_pending"] is True + assert broker.events == [ + ("QUERY_CANCELABLE", "000660", "BUY"), + ("CANCEL", "0000001234", "06010", 2, "00", "KRX"), + ("QUERY_CANCELABLE", "000660", "BUY"), + ("SELL", "000660", 5, 0, "01"), + ] + + +def test_emergency_sell_halts_when_cancel_is_not_confirmed(monkeypatch): + pending = _pending_buy() + broker = _EmergencyCancelBroker( + [{"checked": True, "reason": "ok", "orders": [pending]}] + ) + executor = _prepare_emergency_sell_executor(monkeypatch, broker) + halts = [] + executor._halt_for_uncertain_live_execution = lambda **kwargs: halts.append(kwargs) + monkeypatch.setattr("core.order_executor.time_mod.sleep", lambda *_: None) + + result = executor.execute_sell( + symbol="000660", + price=68_000, + reason="STOP_LOSS", + strategy="safety_test", + ) + + assert result["success"] is False + assert result["emergency_cancel_blocked"] is True + assert result["requires_reconcile"] is True + assert not any(event[0] == "SELL" for event in broker.events) + assert len(halts) == 1 + assert halts[0]["action"] == "CANCEL_BEFORE_EMERGENCY_SELL" + + +def test_emergency_sell_halts_after_cancelling_partially_filled_buy(monkeypatch): + pending = _pending_buy(filled_qty=1) + broker = _EmergencyCancelBroker( + [ + {"checked": True, "reason": "ok", "orders": [pending]}, + {"checked": True, "reason": "ok", "orders": []}, + ] + ) + executor = _prepare_emergency_sell_executor(monkeypatch, broker) + halts = [] + executor._halt_for_uncertain_live_execution = lambda **kwargs: halts.append(kwargs) + + result = executor.execute_sell( + symbol="000660", + price=68_000, + reason="STOP_LOSS", + strategy="safety_test", + ) + + assert result["success"] is False + assert result["emergency_cancel_blocked"] is True + assert "부분 체결" in result["reason"] + assert ("CANCEL", "0000001234", "06010", 2, "00", "KRX") in broker.events + assert not any(event[0] == "SELL" for event in broker.events) + assert len(halts) == 1 + + +def test_emergency_sell_halts_when_cancelled_order_cannot_be_reconciled_locally( + monkeypatch, +): + pending = _pending_buy() + broker = _EmergencyCancelBroker( + [ + {"checked": True, "reason": "ok", "orders": [pending]}, + {"checked": True, "reason": "ok", "orders": []}, + ] + ) + executor = _prepare_emergency_sell_executor(monkeypatch, broker) + executor._mark_cancelled_live_buy_records = lambda *args, **kwargs: ( + (_ for _ in ()).throw(RuntimeError("order DB unavailable")) + ) + halts = [] + executor._halt_for_uncertain_live_execution = lambda **kwargs: halts.append(kwargs) + + result = executor.execute_sell( + symbol="000660", + price=68_000, + reason="STOP_LOSS", + strategy="safety_test", + ) + + assert result["success"] is False + assert result["emergency_cancel_blocked"] is True + assert "로컬 주문 상태" in result["reason"] + assert result["cancel_status"]["local_reconcile_error"] == "order DB unavailable" + assert not any(event[0] == "SELL" for event in broker.events) + assert len(halts) == 1 + + +@pytest.mark.parametrize( + "payload", + [ + { + "output1": [], + "output2": [{"dnca_tot_amt": "NaN", "tot_evlu_amt": "1000000"}], + }, + { + "output1": [], + "output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "inf"}], + }, + { + "output1": [ + { + "pdno": "005930", + "hldg_qty": "NaN", + "pchs_avg_pric": "60000", + "prpr": "61000", + } + ], + "output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "1000000"}], + }, + { + "output1": [ + { + "pdno": "005930", + "hldg_qty": "1", + "pchs_avg_pric": "60000", + "prpr": "NaN", + } + ], + "output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "1000000"}], + }, + ], + ids=["nan-cash", "inf-total-value", "nan-quantity", "nan-current-price"], +) +def test_kis_balance_rejects_nonfinite_numeric_response(payload): + """KIS의 NaN/Inf 잔고 응답을 0이나 유효 잔고로 해석하지 않는다.""" + api = object.__new__(KISApi) + api.use_mock = True + api.cano = "12345678" + api.acnt_prdt_cd = "01" + api._request = lambda *args, **kwargs: {"rt_cd": "0", **payload} + + assert api.get_balance() is None + + +class _SilentNotifier: + def __init__(self, *args, **kwargs): + pass + + def send_message(self, *args, **kwargs): + return True + + +def _capture_real_live_halt(monkeypatch): + halts = [] + guard_extensions = [] + monkeypatch.setattr( + "database.repositories.set_trading_halt", + lambda reason, **kwargs: halts.append({"reason": reason, **kwargs}), + ) + monkeypatch.setattr( + "core.order_guard.OrderGuard.extend_pending", + lambda symbol, ttl_seconds=0: guard_extensions.append( + {"symbol": symbol, "ttl_seconds": ttl_seconds} + ), + ) + monkeypatch.setattr("core.notifier.Notifier", _SilentNotifier) + return halts, guard_extensions + + +def _submitted_live_order(*, partial=False): + order = OrderRecord( + order_id="ORD-UNCERTAIN-SAFETY", + symbol="005380", + action="BUY", + requested_qty=3, + requested_price=60_000, + strategy="safety_test", + account_key="safety_hardening", + mode="live", + ) + order.transition(OrderStatus.SUBMITTED) + order.transition(OrderStatus.ACKED, broker_order_id="BROKER-UNCERTAIN-1") + if partial: + order.transition( + OrderStatus.PARTIAL_FILLED, + fill_qty=1, + fill_price=60_100, + ) + return order + + +def test_real_live_unknown_order_response_sets_global_halt(monkeypatch): + """실계좌 주문 응답이 유실되면 재전송 대신 global HALT를 설정한다.""" + config = _minimal_risk_config(use_mock=False) + executor = _executor_without_external_initialization(config=config, mode="live") + executor._persist_order_record = lambda *args, **kwargs: None + halts, guard_extensions = _capture_real_live_halt(monkeypatch) + order = _submitted_live_order() + + result = executor._unknown_response_result(order, "BUY") + + assert result["response_unknown"] is True + assert result["requires_reconcile"] is True + assert len(halts) == 1 + assert halts[0]["mode"] == "live" + assert halts[0]["source"] == "core.order_executor.uncertain_execution" + assert halts[0]["detail"]["requires_broker_reconcile"] is True + assert halts[0]["detail"]["execution_reason"] == "broker_order_response_unknown" + assert guard_extensions == [{"symbol": "005380", "ttl_seconds": 86_400}] + + +def test_real_live_partial_fill_sets_global_halt(monkeypatch): + """실계좌 부분체결은 잔량 대조 전까지 global HALT와 장기 guard를 남긴다.""" + config = _minimal_risk_config(use_mock=False) + executor = _executor_without_external_initialization(config=config, mode="live") + executor._persist_order_record = lambda *args, **kwargs: None + halts, guard_extensions = _capture_real_live_halt(monkeypatch) + order = _submitted_live_order(partial=True) + execution = { + "confirmed": False, + "reason": "live_partial_fill_unreconciled", + "fill_price": 60_100, + "filled_qty": 1, + "remaining_qty": 2, + } + + result = executor._pending_live_execution_result( + order=order, + action="BUY", + execution=execution, + ) + + assert result["requires_reconcile"] is True + assert result["order_status"] == OrderStatus.PARTIAL_FILLED.value + assert len(halts) == 1 + assert halts[0]["mode"] == "live" + assert halts[0]["detail"]["execution"]["filled_qty"] == 1 + assert halts[0]["detail"]["requires_broker_reconcile"] is True + assert guard_extensions == [{"symbol": "005380", "ttl_seconds": 86_400}] + + +@pytest.mark.parametrize( + "bad_ttl", + [-1, 0, 59, True, "invalid", float("nan"), float("inf")], +) +def test_invalid_pending_guard_ttl_uses_safe_default(bad_ttl): + """오설정된 TTL이 실전 중복 주문 가드를 즉시 만료시키지 않는다.""" + config = _minimal_risk_config(use_mock=False) + config.trading["pending_order_ttl_seconds"] = bad_ttl + executor = _executor_without_external_initialization(config=config, mode="live") + + assert executor._safe_order_guard_ttl( + "pending_order_ttl_seconds", + default=600, + minimum=60, + ) == 600 + + +def test_invalid_reconcile_ttl_still_halts_with_one_day_guard(monkeypatch): + """불명확 체결 사고 처리 중 TTL 파싱 오류로 HALT 자체가 깨지지 않는다.""" + config = _minimal_risk_config(use_mock=False) + config.trading["ledger_reconcile_guard_ttl_seconds"] = "invalid" + executor = _executor_without_external_initialization(config=config, mode="live") + executor._persist_order_record = lambda *args, **kwargs: None + halts, guard_extensions = _capture_real_live_halt(monkeypatch) + + result = executor._unknown_response_result(_submitted_live_order(), "BUY") + + assert result["requires_reconcile"] is True + assert len(halts) == 1 + assert guard_extensions == [{"symbol": "005380", "ttl_seconds": 86_400}] + + +def test_direct_real_kis_order_requires_executor_capability(): + """저수준 KIS API를 직접 호출해 상위 손실 가드를 우회할 수 없다.""" + api = object.__new__(KISApi) + api.use_mock = False + api.cano = "12345678" + api.acnt_prdt_cd = "01" + calls = [] + api._request = lambda *args, **kwargs: calls.append((args, kwargs)) or { + "rt_cd": "0", + "output": {"odno": "SAFE-1"}, + } + + with pytest.raises(PermissionError, match="OrderExecutor"): + api.buy_order("005930", 1, 60_000) + with pytest.raises(PermissionError, match="OrderExecutor"): + api.sell_order("005930", 1, 60_000) + assert calls == [] + + with authorized_kis_order_submission(): + result = api.sell_order("005930", 1, 0, "01") + + assert result == {"odno": "SAFE-1"} + assert calls[0][1]["body"]["ORD_DVSN"] == "01" + assert calls[0][1]["body"]["ORD_UNPR"] == "0" + + +def test_mock_flag_with_real_domain_still_requires_executor_capability(): + """use_mock 오설정만으로 실전 도메인 주문 보호를 끌 수 없다.""" + api = object.__new__(KISApi) + api.use_mock = True + api.base_url = "https://openapi.koreainvestment.com:9443" + api.cano = "12345678" + api.acnt_prdt_cd = "01" + calls = [] + api._request = lambda *args, **kwargs: calls.append((args, kwargs)) + + with pytest.raises(PermissionError, match="OrderExecutor"): + api.buy_order("005930", 1, 60_000) + + assert calls == [] + + +def test_executor_treats_unconfirmed_mock_domain_as_real_money(): + config = _minimal_risk_config(use_mock=True) + config.kis_api["mock_url"] = "https://openapi.koreainvestment.com:9443" + executor = _executor_without_external_initialization(config=config, mode="live") + + assert executor._is_real_money_live() is True diff --git a/tests/test_performance_lens.py b/tests/test_performance_lens.py new file mode 100644 index 00000000..f8a3e63e --- /dev/null +++ b/tests/test_performance_lens.py @@ -0,0 +1,158 @@ +"""국면 분해 · 리스크 지표 렌즈 테스트. + +배경(2026-08-26): 수익률을 한 숫자로 보고하는 동안 '방어의 대가'가 통째로 숨어 있었다. +전체 구간 +7.8%p 초과성과와 반등 구간 -9.0%p 미스가 같은 포트폴리오의 같은 3개월이다. +이 모듈은 그 둘을 나란히 보이게 하는 도구이므로, 여기 산식이 틀리면 잘못된 안도를 준다. +""" + +from datetime import date, datetime + +import pytest + +from core.performance_lens import ( + aligned_returns, + daily_returns_from_nav, + format_regime_line, + format_risk_line, + risk_metrics, + split_by_regime, +) + + +# ------------------------------------------------------------- NAV → 수익률 + +def test_daily_returns_from_nav_basic(): + pts = [(date(2026, 1, 1), 100.0), (date(2026, 1, 2), 110.0), + (date(2026, 1, 3), 99.0)] + out = daily_returns_from_nav(pts) + assert [d for d, _ in out] == [date(2026, 1, 2), date(2026, 1, 3)] + assert out[0][1] == pytest.approx(10.0) + assert out[1][1] == pytest.approx(-10.0) + + +def test_deposits_are_neutralised(): + """입금은 수익이 아니다 — 분모에서 중화하지 않으면 가짜 수익이 잡힌다.""" + pts = [(date(2026, 1, 1), 100.0), (date(2026, 1, 2), 200.0)] + out = daily_returns_from_nav(pts, flows={date(2026, 1, 2): 100.0}) + assert out[0][1] == pytest.approx(0.0) + + +def test_invalid_nav_points_are_skipped(): + pts = [(date(2026, 1, 1), 100.0), (date(2026, 1, 2), None), + (date(2026, 1, 3), 0), (date(2026, 1, 4), 110.0)] + out = daily_returns_from_nav(pts) + assert len(out) == 1 and out[0][1] == pytest.approx(10.0) + + +# ------------------------------------------------------- 기간 정렬 (핵심) + +def test_aligned_returns_uses_same_span_for_benchmark(): + """스냅샷이 빠진 구간에서 벤치마크도 같은 구간으로 계산해야 한다. + + 이게 회귀하면 NAV는 이틀치, 벤치는 하루치가 짝지어져 국면 분해가 통째로 왜곡된다 + (1차 구현에서 상승 국면 벤치마크가 +149%로 나와 전체 수익률과 아귀가 안 맞았다). + """ + nav = [(date(2026, 1, 1), 100.0), (date(2026, 1, 3), 110.0)] # 1/2 결측 + closes = {date(2026, 1, 1): 1000.0, date(2026, 1, 2): 1100.0, + date(2026, 1, 3): 1200.0} + out = aligned_returns(nav, closes) + assert len(out) == 1 + _d, mine, bench = out[0] + assert mine == pytest.approx(10.0) + # 1/1 → 1/3 구간이므로 +20% (하루치 +9.09%가 아니다) + assert bench == pytest.approx(20.0) + + +def test_aligned_returns_drops_spans_without_benchmark(): + nav = [(date(2026, 1, 1), 100.0), (date(2026, 1, 2), 110.0)] + assert aligned_returns(nav, {date(2026, 1, 1): 1000.0}) == [] + + +def test_aligned_returns_accepts_datetime_keys(): + nav = [(datetime(2026, 1, 1), 100.0), (datetime(2026, 1, 2), 110.0)] + closes = {date(2026, 1, 1): 1000.0, date(2026, 1, 2): 1100.0} + assert len(aligned_returns(nav, closes)) == 1 + + +# --------------------------------------------------------------- 국면 분해 + +def test_regime_split_compounds_and_reconciles(): + """상승·하락 복리를 곱하면 전체 수익률이 나와야 한다. + + 단순 합산하면 변동성이 큰 구간에서 아귀가 안 맞는다(실제로 그 버그가 있었다). + """ + pairs = [(1.0, 2.0), (-0.5, -1.0), (2.0, 3.0), (-1.0, -2.0)] + r = split_by_regime(pairs) + assert r["up"]["days"] == 2 and r["down"]["days"] == 2 + + total_bench = 1.0 + for _m, b in pairs: + total_bench *= 1 + b / 100 + combined = (1 + r["up"]["bench_pct"] / 100) * (1 + r["down"]["bench_pct"] / 100) + assert combined == pytest.approx(total_bench) + + +def test_regime_capture_ratio(): + pairs = [(1.0, 2.0), (1.0, 2.0)] + r = split_by_regime(pairs) + # 지수의 절반만 따라간 경우 포착률 ≈ 0.5 + assert r["up"]["capture"] == pytest.approx(0.5, abs=0.01) + assert r["down"]["days"] == 0 + + +def test_flat_benchmark_days_excluded(): + """지수가 정확히 0인 날은 상승도 하락도 아니다.""" + r = split_by_regime([(5.0, 0.0), (1.0, 1.0)]) + assert r["up"]["days"] == 1 and r["down"]["days"] == 0 + + +def test_regime_handles_empty_and_garbage(): + r = split_by_regime([]) + assert r["up"]["days"] == 0 and r["up"]["capture"] is None + r2 = split_by_regime([("x", None), (float("nan"), 1.0)]) + assert r2["up"]["days"] == 0 + + +def test_format_regime_line_leads_with_capture(): + line = format_regime_line(split_by_regime([(1.0, 2.0), (-1.0, -2.0)])) + assert "포착" in line and "상승" in line and "하락" in line + + +def test_format_regime_line_when_no_samples(): + assert "불가" in format_regime_line(split_by_regime([])) + + +# --------------------------------------------------------------- 리스크 지표 + +def test_risk_metrics_basic(): + m = risk_metrics([1.0, -1.0, 2.0, -2.0]) + assert m["samples"] == 4 + assert m["down_day_ratio"] == pytest.approx(0.5) + assert m["worst_day_pct"] == pytest.approx(-2.0) + assert m["best_day_pct"] == pytest.approx(2.0) + assert m["vol_annual_pct"] > 0 + + +def test_risk_metrics_refuses_to_invent_stability(): + """표본이 1개면 변동성을 0으로 채우지 않는다 — 없는 안정성을 주장하게 된다.""" + m = risk_metrics([1.0]) + assert m["samples"] == 1 + assert m["vol_annual_pct"] is None + assert m["sharpe_annual"] is None + assert "산출 불가" in format_risk_line(m) + + +def test_risk_metrics_empty(): + m = risk_metrics([]) + assert m["samples"] == 0 and m["mean_daily_pct"] is None + + +def test_risk_metrics_ignores_non_finite(): + m = risk_metrics([1.0, float("nan"), float("inf"), -1.0, "x", None]) + assert m["samples"] == 2 + + +def test_zero_variance_series_has_no_sharpe(): + m = risk_metrics([0.0, 0.0, 0.0]) + assert m["vol_annual_pct"] == pytest.approx(0.0) + assert m["sharpe_annual"] is None diff --git a/tests/test_portfolio_manager.py b/tests/test_portfolio_manager.py index 117f21aa..d15caff6 100644 --- a/tests/test_portfolio_manager.py +++ b/tests/test_portfolio_manager.py @@ -68,7 +68,7 @@ def fresh_db(): def test_portfolio_summary_uses_trade_cash_flow(monkeypatch): monkeypatch.setattr( "core.portfolio_manager.get_all_positions", - lambda account_key=None: [_MockPosition("005930", 50000, 10)], + lambda account_key=None, mode="paper": [_MockPosition("005930", 50000, 10)], ) monkeypatch.setattr( "core.portfolio_manager.get_trade_cash_summary", @@ -93,6 +93,73 @@ def test_portfolio_summary_uses_trade_cash_flow(monkeypatch): assert summary["unrealized_pnl"] == 50000 +def test_daily_snapshot_requires_complete_finite_market_prices(monkeypatch): + """보유 종목을 평균단가로 평가한 가짜 NAV는 증거 장부에 저장하지 않는다.""" + saved = [] + monkeypatch.setattr( + "core.portfolio_manager.get_all_positions", + lambda account_key=None, mode="paper": [ + _MockPosition("005930", 50000, 10) + ], + ) + monkeypatch.setattr( + "core.portfolio_manager.get_trade_cash_summary", + lambda mode=None, account_key=None: {"cash_delta": -500000}, + ) + monkeypatch.setattr( + "core.portfolio_manager.get_cash_flow_total", + lambda account_key="", mode="paper": 0, + ) + monkeypatch.setattr( + "core.portfolio_manager.has_cash_flows", + lambda account_key="", mode="paper": False, + ) + monkeypatch.setattr( + "core.portfolio_manager.get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) + monkeypatch.setattr( + "core.portfolio_manager.save_portfolio_snapshot", + lambda **kwargs: saved.append(kwargs) or True, + ) + + pm = PortfolioManager(_MockConfig(), account_key="scoring") + + assert pm.save_daily_snapshot() is False + assert pm.save_daily_snapshot({"005930": float("nan")}) is False + assert pm.save_daily_snapshot({"005930": 55000}) is True + assert len(saved) == 1 + assert saved[0]["total_value"] == 1050000 + assert saved[0]["mode"] == "paper" + + +@pytest.mark.parametrize( + "avg_price,quantity", + [ + (float("nan"), 1), + (50000, float("inf")), + (50000, 1.5), + ], +) +def test_portfolio_summary_rejects_corrupt_position_numbers( + monkeypatch, avg_price, quantity +): + monkeypatch.setattr( + "core.portfolio_manager.get_all_positions", + lambda account_key=None, mode="paper": [ + _MockPosition("005930", avg_price, quantity) + ], + ) + monkeypatch.setattr( + "core.portfolio_manager.get_latest_peak_value", + lambda account_key="", mode="paper": None, + ) + + pm = PortfolioManager(_MockConfig()) + with pytest.raises(ValueError): + pm.get_portfolio_summary({"005930": 55000}) + + def test_live_portfolio_summary_marks_broker_balance_fallback(monkeypatch): """live 잔고 조회 실패 시 DB fallback을 표시하고 주문 sizing용 자본 조회는 차단한다.""" from core.portfolio_manager import LiveBrokerBalanceUnavailable @@ -102,7 +169,10 @@ def test_live_portfolio_summary_marks_broker_balance_fallback(monkeypatch): "get_balance", lambda self: (_ for _ in ()).throw(RuntimeError("kis down")), ) - monkeypatch.setattr("core.portfolio_manager.get_all_positions", lambda account_key=None: []) + monkeypatch.setattr( + "core.portfolio_manager.get_all_positions", + lambda account_key=None, mode="paper": [], + ) monkeypatch.setattr( "core.portfolio_manager.get_trade_cash_summary", lambda mode=None, account_key=None: { @@ -148,7 +218,7 @@ def send_message(self, text, critical=False): ) monkeypatch.setattr( "core.portfolio_manager.get_all_positions", - lambda account_key=None: [_MockPosition("005930", 50000, 10)], + lambda account_key=None, mode="paper": [_MockPosition("005930", 50000, 10)], ) monkeypatch.setattr(core.notifier, "Notifier", _FakeNotifier) @@ -185,7 +255,7 @@ def _record_auto_correct(self, mismatches): ) monkeypatch.setattr( "core.portfolio_manager.get_all_positions", - lambda account_key=None: [_MockPosition("005930", 50000, 10)], + lambda account_key=None, mode="paper": [_MockPosition("005930", 50000, 10)], ) monkeypatch.setattr(core.notifier, "Notifier", _FakeNotifier) monkeypatch.setattr(PortfolioManager, "_auto_correct_positions", _record_auto_correct) diff --git a/tests/test_portfolio_regime_filter.py b/tests/test_portfolio_regime_filter.py index cae8b88d..5fbad056 100644 --- a/tests/test_portfolio_regime_filter.py +++ b/tests/test_portfolio_regime_filter.py @@ -63,6 +63,7 @@ def test_no_buys_in_bearish_regime(self): all_dates=all_dates, initial_capital=100_000_000, regime_series=bearish_series, + execution_model="legacy_same_close", ) buy_trades = [t for t in result["trades"] if t["action"] == "BUY"] @@ -100,6 +101,7 @@ def _run_with_regime(self, regime_label: str, capital: int = 100_000_000): all_dates=all_dates, initial_capital=capital, regime_series=regime_series, + execution_model="legacy_same_close", ) buy_trades = [t for t in result["trades"] if t["action"] == "BUY"] return buy_trades, result @@ -343,6 +345,41 @@ def _make_portfolio_guard_df(close, *, open_=None, signals=None, volume=1_000_00 return df +def test_portfolio_strategy_signals_execute_one_bar_later_at_next_open(): + """포트폴리오 전략 주문도 신호일 다음 거래일의 시가를 사용한다.""" + from backtest.portfolio_backtester import PortfolioBacktester + + sym = "CAUSAL01" + df = _make_portfolio_guard_df( + [100.0, 125.0, 126.0, 130.0], + open_=[90.0, 123.0, 126.0, 124.0], + signals=["BUY", "HOLD", "SELL", "HOLD"], + ) + pbt = PortfolioBacktester(_PortfolioGuardConfig(gap_enabled=False)) + + result = pbt._simulate_portfolio( + symbols=[sym], + signals={sym: df}, + data={}, + all_dates=list(df.index), + initial_capital=100_000.0, + ) + + assert result["execution_model"] == "next_open" + assert [trade["action"] for trade in result["trades"]] == ["BUY", "SELL"] + buy, sell = result["trades"] + assert buy["signal_date"] == df.index[0] + assert buy["date"] == df.index[1] + assert buy["price"] == pytest.approx(123.0) + assert sell["signal_date"] == df.index[2] + assert sell["date"] == df.index[3] + assert sell["price"] == pytest.approx(124.0) + assert not any(trade["date"] in {df.index[0], df.index[2]} for trade in result["trades"]) + + metrics = pbt._calculate_portfolio_metrics(result, initial_capital=100_000.0) + assert metrics["execution_model"] == "next_open" + + class TestPortfolioRiskEventGuards: """paper/live 리스크 이벤트를 포트폴리오 백테스트에도 반영한다.""" @@ -363,6 +400,7 @@ def test_gap_up_blocks_new_buy(self): data={}, all_dates=list(df.index), initial_capital=100_000.0, + execution_model="legacy_same_close", ) assert [t["action"] for t in result["trades"]] == [] @@ -387,6 +425,7 @@ def test_earnings_window_blocks_new_buy(self): data={}, all_dates=list(df.index), initial_capital=100_000.0, + execution_model="legacy_same_close", ) assert [t["action"] for t in result["trades"]] == [] @@ -410,6 +449,7 @@ def test_gap_down_exit_preempts_close_stop_loss(self): data={}, all_dates=list(df.index), initial_capital=100_000.0, + execution_model="legacy_same_close", ) assert [t["action"] for t in result["trades"]] == ["BUY", "GAP_DOWN"] @@ -439,6 +479,7 @@ def test_blackswan_exit_blocks_cooldown_and_scales_recovery_buy(self): data={}, all_dates=list(df.index), initial_capital=100_000.0, + execution_model="legacy_same_close", ) assert [t["action"] for t in result["trades"]] == ["BUY", "BLACKSWAN", "BUY"] @@ -505,6 +546,7 @@ def calculate_transaction_costs( action="BUY", avg_daily_volume=None, avg_price=None, + symbol=None, ): self.calls.append( { @@ -512,6 +554,7 @@ def calculate_transaction_costs( "avg_daily_volume": avg_daily_volume, "quantity": quantity, "avg_price": avg_price, + "symbol": symbol, } ) participation = quantity / avg_daily_volume if avg_daily_volume else 0 @@ -543,12 +586,14 @@ def calculate_transaction_costs( data={}, all_dates=list(df.index), initial_capital=100_000.0, + execution_model="legacy_same_close", ) actions = [call["action"] for call in recorder.calls] assert actions == ["BUY", "SELL"] assert recorder.calls[0]["avg_daily_volume"] == 100.0 assert recorder.calls[1]["avg_daily_volume"] == 200.0 + assert [call["symbol"] for call in recorder.calls] == [sym, sym] buy_trade, sell_trade = result["trades"] assert [buy_trade["action"], sell_trade["action"]] == ["BUY", "SELL"] assert buy_trade["participation_rate"] == pytest.approx(2.0) diff --git a/tests/test_position_unique_migration.py b/tests/test_position_unique_migration.py index d1878062..94334c3b 100644 --- a/tests/test_position_unique_migration.py +++ b/tests/test_position_unique_migration.py @@ -1,4 +1,4 @@ -"""positions 구버전 UNIQUE(symbol) → (account_key, symbol) 재구축 마이그레이션 테스트. +"""positions 구버전 UNIQUE → (mode, account_key, symbol) 재구축 테스트. 배경(2026-07-07 실측): account_key 도입 전 스키마의 symbol 단독 유니크가 물리 테이블에 남아 있어(모델은 이미 복합 제약 — create_all은 기존 테이블을 못 바꾼다), @@ -39,6 +39,10 @@ def _legacy_engine(tmp_path, symbols=("005930", "069500")): engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") with engine.connect() as conn: conn.execute(text(LEGACY_DDL)) + # ALTER TABLE RENAME 후에도 이름이 남는 실제 ORM 인덱스를 재현한다. + conn.execute(text( + "CREATE INDEX ix_positions_account_key ON positions (account_key)" + )) for i, sym in enumerate(symbols): conn.execute(text( "INSERT INTO positions " @@ -49,8 +53,31 @@ def _legacy_engine(tmp_path, symbols=("005930", "069500")): return engine +def _add_trade_modes(engine, rows): + """mode 역산에 필요한 최소 구버전 trade_history 스키마.""" + with engine.connect() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS trade_history ( + id INTEGER PRIMARY KEY, + account_key VARCHAR(64) NOT NULL, + symbol VARCHAR(20) NOT NULL, + mode VARCHAR(20) + ) + """)) + for account_key, symbol, mode in rows: + conn.execute(text( + "INSERT INTO trade_history (account_key, symbol, mode) " + "VALUES (:account_key, :symbol, :mode)" + ), { + "account_key": account_key, + "symbol": symbol, + "mode": mode, + }) + conn.commit() + + class TestPositionUniqueMigration: - def test_rebuild_preserves_rows_and_allows_same_symbol_two_accounts(self, tmp_path): + def test_rebuild_preserves_rows_and_allows_same_key_in_paper(self, tmp_path): engine = _legacy_engine(tmp_path) _migrate_position_unique_constraint(engine) @@ -61,18 +88,22 @@ def test_rebuild_preserves_rows_and_allows_same_symbol_two_accounts(self, tmp_pa "SELECT avg_price FROM positions WHERE symbol = '069500'" )).scalar() assert avg == pytest.approx(100001.0) - # 핵심: 같은 종목을 다른 계좌(아카이브 키·전략 트랙)가 이제 들 수 있어야 한다 + # 무이력 기존 행은 legacy로 격리된다. + assert conn.execute(text( + "SELECT mode FROM positions WHERE symbol = '069500'" + )).scalar() == "legacy" + # 같은 계좌+종목이어도 신규 paper 장부는 독립 보유 가능해야 한다. conn.execute(text( "INSERT INTO positions (symbol, avg_price, quantity, total_invested, account_key, partial_tp_done) " - "VALUES ('069500', 123810, 1, 123810, 'basket_rebalance:kr_pocket', 0)" + "VALUES ('069500', 123810, 1, 123810, 'basket_rebalance:kr_x', 0)" )) conn.commit() assert conn.execute(text("SELECT COUNT(1) FROM positions")).scalar() == 3 - # 같은 계좌·같은 종목은 여전히 차단(복합 유니크) + # 같은 mode+계좌+종목은 여전히 차단한다. with pytest.raises(Exception): conn.execute(text( "INSERT INTO positions (symbol, avg_price, quantity, total_invested, account_key, partial_tp_done) " - "VALUES ('069500', 1, 1, 1, 'basket_rebalance:kr_pocket', 0)" + "VALUES ('069500', 1, 1, 1, 'basket_rebalance:kr_x', 0)" )) def test_idempotent_on_new_schema(self, tmp_path): @@ -84,7 +115,8 @@ def test_idempotent_on_new_schema(self, tmp_path): ddl = conn.execute(text( "SELECT sql FROM sqlite_master WHERE type='table' AND name='positions'" )).scalar() - assert "uq_positions_account_symbol" in ddl + assert "uq_positions_mode_account_symbol" in ddl + assert "mode VARCHAR(20) DEFAULT 'paper' NOT NULL" in ddl def test_resume_after_interrupted_rename(self, tmp_path): # rename 직후 중단된 상태(legacy 테이블 존재 + 본 테이블 부재)에서 재개 @@ -99,6 +131,41 @@ def test_resume_after_interrupted_rename(self, tmp_path): "SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='positions_legacy_uq'" )).scalar() assert legacy == 0 + # rename으로 따라간 기존 인덱스 이름도 신 테이블에 정상 재생성. + indexed_table = conn.execute(text( + "SELECT tbl_name FROM sqlite_master WHERE type='index' " + "AND name='ix_positions_account_key'" + )).scalar() + assert indexed_table == "positions" + + def test_single_trade_mode_is_inferred(self, tmp_path): + engine = _legacy_engine(tmp_path, symbols=("069500",)) + _add_trade_modes(engine, [ + ("basket_rebalance:kr_x", "069500", "LIVE"), + ("basket_rebalance:kr_x", "069500", "live"), + ]) + + _migrate_position_unique_constraint(engine) + + with engine.connect() as conn: + assert conn.execute(text( + "SELECT mode FROM positions WHERE symbol='069500'" + )).scalar() == "live" + + def test_mixed_or_missing_trade_mode_is_quarantined(self, tmp_path): + engine = _legacy_engine(tmp_path, symbols=("005930", "069500")) + _add_trade_modes(engine, [ + ("basket_rebalance:kr_x", "005930", "paper"), + ("basket_rebalance:kr_x", "005930", "live"), + ]) + + _migrate_position_unique_constraint(engine) + + with engine.connect() as conn: + modes = dict(conn.execute(text( + "SELECT symbol, mode FROM positions ORDER BY symbol" + )).fetchall()) + assert modes == {"005930": "legacy", "069500": "legacy"} def test_operational_incident_sequence(self, tmp_path): """7/7 실측 시나리오 재연: 아카이브 키가 같은 종목을 든 상태에서 본 키 매수. diff --git a/tests/test_postgres_ledger_mode_migration.py b/tests/test_postgres_ledger_mode_migration.py new file mode 100644 index 00000000..74b26b44 --- /dev/null +++ b/tests/test_postgres_ledger_mode_migration.py @@ -0,0 +1,176 @@ +"""PostgreSQL mode 장부 DDL 전환의 멱등성·트랜잭션 복구 검증. + +로컬 CI에 PostgreSQL 서버·드라이버가 없으므로 PostgreSQL dialect의 식별자 +규칙은 그대로 쓰고, pg_catalog 응답과 transactional DDL 상태만 결정론적으로 +모의한다. 실행 SQL 순서와 중단 rollback 후 재시도 계약을 검증한다. +""" + +from copy import deepcopy + +import pytest +from sqlalchemy.dialects import postgresql + +from database.models import ( + _migrate_position_unique_constraint, + _migrate_snapshot_unique_constraint, +) + + +class _Result: + def __init__(self, *, scalar=None, rows=()): + self._scalar = scalar + self._rows = list(rows) + + def scalar(self): + return self._scalar + + def fetchall(self): + return list(self._rows) + + +class _PostgresUrl: + @staticmethod + def get_dialect(): + return type("DialectName", (), {"name": "postgresql"}) + + +class _Transaction: + def __init__(self, engine): + self.engine = engine + self.snapshot = None + + def __enter__(self): + self.snapshot = deepcopy(self.engine.constraints) + return self.engine.connection + + def __exit__(self, exc_type, exc, traceback): + if exc_type is not None: + self.engine.constraints = self.snapshot + self.engine.rollbacks += 1 + return False + self.engine.commits += 1 + return False + + +class _PostgresConnection: + def __init__(self, engine): + self.engine = engine + + def execute(self, statement, parameters=None): + sql = " ".join(str(statement).split()) + params = parameters or {} + self.engine.statements.append(sql) + + if sql.startswith("SELECT to_regclass"): + return _Result(scalar=params["table_name"]) + if "FROM pg_constraint AS c" in sql: + return _Result(rows=self.engine.constraints.items()) + if "FROM information_schema.columns" in sql: + return _Result(scalar=len(params["columns"])) + if " DROP CONSTRAINT " in sql: + name = sql.rsplit(" ", 1)[-1].strip('"') + self.engine.constraints.pop(name, None) + if " ADD CONSTRAINT " in sql: + if self.engine.fail_add_constraint: + raise RuntimeError("simulated PostgreSQL interruption") + name = sql.split(" ADD CONSTRAINT ", 1)[1].split(" ", 1)[0].strip('"') + definition = "UNIQUE (" + sql.rsplit("UNIQUE (", 1)[1] + self.engine.constraints[name] = definition + return _Result() + + +class _PostgresEngine: + def __init__(self, constraints): + self.url = _PostgresUrl() + self.dialect = postgresql.dialect() + self.constraints = dict(constraints) + self.connection = _PostgresConnection(self) + self.statements = [] + self.commits = 0 + self.rollbacks = 0 + self.fail_add_constraint = False + + def begin(self): + return _Transaction(self) + + +@pytest.mark.parametrize( + ("migration", "old_name", "old_definition", "new_name", "new_columns"), + [ + ( + _migrate_position_unique_constraint, + "uq_positions_account_symbol", + "UNIQUE (account_key, symbol)", + "uq_positions_mode_account_symbol", + "mode, account_key, symbol", + ), + ( + _migrate_snapshot_unique_constraint, + "uq_snapshots_account_date", + "UNIQUE (account_key, date)", + "uq_snapshots_mode_account_date", + "mode, account_key, date", + ), + ], +) +def test_postgres_mode_migration_is_idempotent( + migration, old_name, old_definition, new_name, new_columns +): + engine = _PostgresEngine({old_name: old_definition}) + + migration(engine) + + assert old_name not in engine.constraints + assert engine.constraints[new_name] == f"UNIQUE ({new_columns})" + assert engine.commits == 1 + assert any("ADD COLUMN IF NOT EXISTS mode VARCHAR(20)" in sql for sql in engine.statements) + assert any("SET DEFAULT 'paper'" in sql for sql in engine.statements) + assert any("ALTER COLUMN mode SET NOT NULL" in sql for sql in engine.statements) + inference_count = sum(sql.startswith("WITH inferred AS") for sql in engine.statements) + assert inference_count == 1 + + migration(engine) + + assert engine.commits == 2 + assert engine.constraints[new_name] == f"UNIQUE ({new_columns})" + # 완료 후 재실행은 legacy 행을 새 거래 이력으로 재분류하지 않는다. + assert sum(sql.startswith("WITH inferred AS") for sql in engine.statements) == 1 + assert sum(f"ADD CONSTRAINT {new_name}" in sql for sql in engine.statements) == 1 + + +@pytest.mark.parametrize( + ("migration", "old_name", "old_definition", "new_name"), + [ + ( + _migrate_position_unique_constraint, + "uq_positions_account_symbol", + "UNIQUE (account_key, symbol)", + "uq_positions_mode_account_symbol", + ), + ( + _migrate_snapshot_unique_constraint, + "uq_snapshots_account_date", + "UNIQUE (account_key, date)", + "uq_snapshots_mode_account_date", + ), + ], +) +def test_postgres_interruption_rolls_back_and_next_run_recovers( + migration, old_name, old_definition, new_name +): + engine = _PostgresEngine({old_name: old_definition}) + engine.fail_add_constraint = True + + with pytest.raises(RuntimeError, match="simulated PostgreSQL interruption"): + migration(engine) + + assert engine.rollbacks == 1 + assert engine.constraints == {old_name: old_definition} + assert new_name not in engine.constraints + + engine.fail_add_constraint = False + migration(engine) + + assert engine.commits == 1 + assert old_name not in engine.constraints + assert new_name in engine.constraints diff --git a/tests/test_rebalance_snapshot.py b/tests/test_rebalance_snapshot.py index a9d9cb4d..9a341b71 100644 --- a/tests/test_rebalance_snapshot.py +++ b/tests/test_rebalance_snapshot.py @@ -31,6 +31,9 @@ def patched_rebalance(monkeypatch): fake_rb.get_status_report.return_value = "status" # 거래가 발생하지 않는 날에도 스냅샷은 저장돼야 한다. fake_rb.should_rebalance.return_value = (False, "드리프트 미달") + # 손절/익절에 걸린 포지션이 없는 평범한 날 — MagicMock 기본 반환(truthy)을 그대로 + # 두면 매 사이클 리스크 청산이 발동한 것처럼 보인다. + fake_rb.plan_risk_exits.return_value = [] monkeypatch.setattr( "core.basket_rebalancer.BasketRebalancer", MagicMock(return_value=fake_rb) ) diff --git a/tests/test_restart_track_record.py b/tests/test_restart_track_record.py index a5825fd5..81a4db8e 100644 --- a/tests/test_restart_track_record.py +++ b/tests/test_restart_track_record.py @@ -5,25 +5,36 @@ import subprocess from datetime import datetime -VENV = r".venv\Scripts\python.exe" +VENV = sys.executable KEY = "basket_rebalance:rt_test" def _seed(): - from database.models import get_session, init_database, TradeHistory, PortfolioSnapshot + from database.models import ( + CashFlow, + Position, + PortfolioSnapshot, + TradeHistory, + get_session, + init_database, + ) init_database() s = get_session() try: - s.query(TradeHistory).filter(TradeHistory.account_key.like(f"{KEY}%")).delete( - synchronize_session=False) - s.query(PortfolioSnapshot).filter(PortfolioSnapshot.account_key.like(f"{KEY}%")).delete( - synchronize_session=False) + for model in (TradeHistory, Position, PortfolioSnapshot, CashFlow): + s.query(model).filter(model.account_key.like(f"{KEY}%")).delete( + synchronize_session=False) s.add(TradeHistory(symbol="005930", action="BUY", quantity=1, price=60000, total_amount=60000, strategy=KEY, mode="paper", account_key=KEY, executed_at=datetime(2026, 6, 10, 10, 0))) + s.add(Position(mode="paper", account_key=KEY, symbol="005930", + avg_price=60_000, quantity=1, total_invested=60_000, + strategy=KEY)) s.add(PortfolioSnapshot(account_key=KEY, date=datetime(2026, 6, 10), total_value=10_000_000, cash=4_000_000, invested=6_000_000, mdd=0.0, position_count=1)) + s.add(CashFlow(mode="paper", account_key=KEY, amount=100_000, + occurred_at=datetime(2026, 6, 10, 9, 0), note="paper")) s.commit() finally: s.close() @@ -43,7 +54,10 @@ def _counts(key): def _run(*extra): return subprocess.run( [VENV, "tools/restart_basket_track_record.py", "--basket", "rt_test", *extra], - capture_output=True, text=True, env={**os.environ}, + capture_output=True, + text=True, + env={**os.environ}, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ) @@ -74,3 +88,61 @@ def test_undo_fails_closed_when_live_records_exist(): _seed() # 살아있는 키에 새 기록 생성 r = _run("--undo", "--archive-suffix", "t3", "--apply") assert r.returncode == 1 # 섞임 방지 + + +def test_apply_archives_only_paper_ledger_rows(): + from database.models import ( + CashFlow, + Position, + PortfolioSnapshot, + TradeHistory, + get_session, + ) + + _seed() + s = get_session() + try: + s.add(TradeHistory( + symbol="005930", action="BUY", quantity=2, price=61_000, + total_amount=122_000, strategy=KEY, mode="live", + account_key=KEY, executed_at=datetime(2026, 6, 10, 10, 1), + )) + s.add(Position( + mode="live", account_key=KEY, symbol="005930", + avg_price=61_000, quantity=2, total_invested=122_000, + strategy=KEY, + )) + s.add(PortfolioSnapshot( + mode="live", account_key=KEY, date=datetime(2026, 6, 10), + total_value=20_000_000, cash=10_000_000, + invested=10_000_000, mdd=0.0, position_count=1, + )) + s.add(CashFlow( + mode="live", account_key=KEY, amount=200_000, + occurred_at=datetime(2026, 6, 10, 9, 1), note="live", + )) + s.commit() + finally: + s.close() + + archive_key = f"{KEY}@t-mode" + result = _run("--archive-suffix", "t-mode", "--apply") + assert result.returncode == 0, result.stderr + + s = get_session() + try: + for model in (TradeHistory, Position, PortfolioSnapshot, CashFlow): + assert s.query(model).filter( + model.mode == "paper", model.account_key == KEY, + ).count() == 0 + assert s.query(model).filter( + model.mode == "paper", model.account_key == archive_key, + ).count() == 1 + assert s.query(model).filter( + model.mode == "live", model.account_key == KEY, + ).count() == 1 + assert s.query(model).filter( + model.mode == "live", model.account_key == archive_key, + ).count() == 0 + finally: + s.close() diff --git a/tests/test_risk_manager.py b/tests/test_risk_manager.py index ddf5f376..89ca02c4 100644 --- a/tests/test_risk_manager.py +++ b/tests/test_risk_manager.py @@ -55,6 +55,12 @@ def test_position_size_risk_per_share_zero(risk_manager): assert risk_manager.calculate_position_size(10_000_000, 50_000, 50_000) == 0 +@pytest.mark.parametrize("stop_price", [0, -1, 50_001, float("nan"), float("inf"), "49000", None, True]) +def test_position_size_rejects_invalid_stop_price(risk_manager, stop_price): + """손절가가 양수가 아니거나 진입가 이상/비유한 값이면 fail-closed.""" + assert risk_manager.calculate_position_size(10_000_000, 50_000, stop_price) == 0 + + def test_position_size_capital_zero(risk_manager): """자본 0이면 0 반환""" assert risk_manager.calculate_position_size(0, 50_000, 49_000) == 0 @@ -67,6 +73,28 @@ def test_position_size_normal(risk_manager): assert isinstance(qty, int) +def test_signal_scaling_cannot_exceed_max_risk_per_trade(risk_manager): + """강한 신호도 설정된 1회 손실 예산을 확대하지 못한다.""" + risk_manager.risk_params["position_sizing"]["signal_scaling"] = { + "enabled": True, + "min_scale": 0.5, + "max_scale": 1.5, + "score_range": [2, 5], + } + capital = 10_000_000 + entry = 50_000 + stop = 49_000 + qty = risk_manager.calculate_position_size(capital, entry, stop, signal_score=5) + + assert qty * (entry - stop) <= capital * 0.01 + + +@pytest.mark.parametrize("bad_risk", [0, -0.01, 0.051, 1.0, float("nan"), float("inf"), "0.01", True]) +def test_invalid_max_risk_setting_blocks_new_position(risk_manager, bad_risk): + risk_manager.risk_params["position_sizing"]["max_risk_per_trade"] = bad_risk + assert risk_manager.calculate_position_size(10_000_000, 50_000, 49_000) == 0 + + def test_tick_size(): """호가 단위 헬퍼 (KRX: 5만원 미만 50원, 이상 100원 등)""" assert _get_tick_size(1000) == 1 @@ -206,6 +234,40 @@ def test_dynamic_slippage_increases_when_order_participation_is_high(risk_manage assert high["slippage_multiplier"] >= 2.0 +@pytest.mark.parametrize("bad_price", [0, -1, float("nan"), float("inf"), "50000", True]) +def test_transaction_costs_reject_invalid_price(risk_manager, bad_price): + with pytest.raises(ValueError, match="price"): + risk_manager.calculate_transaction_costs(bad_price, 1, "BUY") + + +@pytest.mark.parametrize("bad_quantity", [0, -1, float("nan"), float("inf"), "1", True]) +def test_transaction_costs_reject_invalid_quantity(risk_manager, bad_quantity): + with pytest.raises(ValueError, match="quantity"): + risk_manager.calculate_transaction_costs(50_000, bad_quantity, "BUY") + + +def test_transaction_costs_allow_positive_fractional_quantity_for_research(risk_manager): + result = risk_manager.calculate_transaction_costs(50_000, 1.5, "BUY") + assert result["total_cost"] > 0 + + +@pytest.mark.parametrize("bad_action", ["", "HOLD", "PURCHASE", None]) +def test_transaction_costs_reject_unknown_action(risk_manager, bad_action): + with pytest.raises(ValueError, match="action"): + risk_manager.calculate_transaction_costs(50_000, 1, bad_action) + + +@pytest.mark.parametrize("bad_avg_price", [0, -1, float("nan"), float("inf"), "50000"]) +def test_transaction_costs_reject_invalid_average_price(risk_manager, bad_avg_price): + with pytest.raises(ValueError, match="avg_price"): + risk_manager.calculate_transaction_costs( + 50_000, + 1, + "SELL", + avg_price=bad_avg_price, + ) + + def test_correlation_risk_blocks_when_target_price_data_lookup_fails(risk_manager, monkeypatch): """상관관계 리스크가 켜져 있으면 대상 종목 가격 조회 실패 시 fail-closed.""" @@ -251,6 +313,76 @@ def fetch_stock(self, symbol): assert result["missing_symbols"] == ["000660"] +@pytest.mark.parametrize( + ("key", "bad_value"), + [ + ("high_corr_threshold", float("nan")), + ("high_corr_scale", 0), + ("lookback_days", 29), + ("strict", "true"), + ], +) +def test_correlation_risk_invalid_runtime_config_blocks_without_data_lookup( + risk_manager, monkeypatch, key, bad_value +): + risk_manager.risk_params["diversification"]["correlation_risk"][key] = bad_value + monkeypatch.setattr( + "core.data_collector.DataCollector", + lambda: pytest.fail("invalid config must block before data lookup"), + ) + + result = risk_manager.check_correlation_risk("005930", ["000660"]) + + assert result["blocked"] is True + assert result["scale"] == 0.0 + assert "설정 오류" in result["reason"] + + +def test_performance_degradation_nan_threshold_blocks(risk_manager): + risk_manager.risk_params["performance_degradation"] = { + "enabled": True, + "recent_trades": 20, + "min_win_rate": float("nan"), + } + + result = risk_manager.check_recent_performance([]) + + assert result["allowed"] is False + assert result["performance_config_invalid"] is True + + +def test_projected_exposure_rejects_boolean_max_positions(risk_manager): + risk_manager.risk_params["diversification"]["max_positions"] = True + + result = risk_manager.check_projected_exposure( + current_positions=0, + position_value=100_000, + total_value=1_000_000, + available_cash=1_000_000, + current_invested=0, + ) + + assert result["can_buy"] is False + assert result["exposure_check_failed"] is True + + +def test_diversification_nan_sector_cap_blocks(risk_manager): + risk_manager.risk_params["diversification"]["max_sector_ratio"] = float("nan") + + result = risk_manager.check_diversification( + current_positions=0, + position_value=100_000, + total_value=1_000_000, + available_cash=1_000_000, + symbol="005930", + sector_map={"005930": "반도체"}, + positions=[], + ) + + assert result["can_buy"] is False + assert "설정 오류" in result["reason"] + + class TestEtfSellTaxExemption: """국내 상장 ETF 매도세 면제 — tax_exempt_symbols 등록 종목은 매도세 0. @@ -267,6 +399,11 @@ class _Cfg: "tax_rate": 0.0020, # yaml에서 숫자로 적혀도(따옴표 누락) 문자열 비교로 매칭돼야 한다 "tax_exempt_symbols": ["069500", 357870], + "holding_period_income_tax": { + "enabled": True, + "rate": 0.154, + "symbols": [357870], + }, "slippage": 0.0005, "slippage_ticks": 1, "dynamic_slippage": {"enabled": False}, @@ -282,6 +419,42 @@ def test_int_coded_yaml_entry_still_matches(self, rm): out = rm.calculate_transaction_costs(57715, 2, "SELL", symbol="357870") assert out["tax"] == 0 + def test_other_etf_positive_gain_applies_holding_period_income_tax(self, rm): + out = rm.calculate_transaction_costs( + 60_000, + 2, + "SELL", + avg_price=50_000, + symbol="357870", + ) + expected = round((60_000 - 50_000) * 2 * 0.154, 0) + assert out["transaction_tax"] == 0 + assert out["holding_period_income_tax"] == expected + assert out["tax"] == expected + + def test_other_etf_loss_has_no_holding_period_income_tax(self, rm): + out = rm.calculate_transaction_costs( + 50_000, + 2, + "SELL", + avg_price=60_000, + symbol="357870", + ) + assert out["holding_period_income_tax"] == 0 + assert out["tax"] == 0 + + def test_domestic_equity_etf_gain_remains_trade_gain_tax_free(self, rm): + out = rm.calculate_transaction_costs( + 60_000, + 2, + "SELL", + avg_price=50_000, + symbol="069500", + ) + assert out["transaction_tax"] == 0 + assert out["holding_period_income_tax"] == 0 + assert out["tax"] == 0 + def test_non_exempt_symbol_keeps_tax(self, rm): out = rm.calculate_transaction_costs(70000, 10, "SELL", symbol="005930") assert out["tax"] == round(700000 * 0.0020, 0) diff --git a/tests/test_runtime_lock.py b/tests/test_runtime_lock.py new file mode 100644 index 00000000..9ef14b66 --- /dev/null +++ b/tests/test_runtime_lock.py @@ -0,0 +1,129 @@ +from contextlib import contextmanager +import multiprocessing +from types import SimpleNamespace + +import pytest + + +def _try_runtime_lock_in_child(lock_path, result_queue): + from core.runtime_lock import process_runtime_lock + + with process_runtime_lock(lock_path, label="child") as acquired: + result_queue.put(acquired) + + +def test_process_runtime_lock_is_exclusive_and_releases(tmp_path): + from core.runtime_lock import process_runtime_lock + + lock_path = tmp_path / "runtime.lock" + with process_runtime_lock(lock_path, label="first") as first: + assert first is True + with process_runtime_lock(lock_path, label="second") as second: + assert second is False + + with process_runtime_lock(lock_path, label="after-release") as acquired_again: + assert acquired_again is True + + +def test_process_runtime_lock_blocks_a_separate_process(tmp_path): + from core.runtime_lock import process_runtime_lock + + lock_path = tmp_path / "runtime.lock" + context = multiprocessing.get_context("spawn") + result_queue = context.Queue() + + with process_runtime_lock(lock_path, label="parent") as acquired: + assert acquired is True + child = context.Process( + target=_try_runtime_lock_in_child, + args=(lock_path, result_queue), + ) + child.start() + assert result_queue.get(timeout=10) is False + child.join(timeout=10) + + assert child.exitcode == 0 + + +def test_process_runtime_lock_fails_closed_without_backend(tmp_path, monkeypatch): + import core.runtime_lock as runtime_lock + + monkeypatch.setattr(runtime_lock, "fcntl", None) + monkeypatch.setattr(runtime_lock, "msvcrt", None) + + with runtime_lock.process_runtime_lock(tmp_path / "runtime.lock") as acquired: + assert acquired is False + + +def test_process_runtime_lock_fails_closed_when_lock_file_unavailable(tmp_path): + from core.runtime_lock import process_runtime_lock + + not_a_directory = tmp_path / "not-a-directory" + not_a_directory.write_text("block child creation", encoding="utf-8") + + with process_runtime_lock(not_a_directory / "runtime.lock") as acquired: + assert acquired is False + + +def test_live_runtime_lock_uses_one_global_path(tmp_path): + from core.runtime_lock import LIVE_RUNTIME_LOCK_FILENAME, live_runtime_lock + + with live_runtime_lock(tmp_path) as first: + assert first is True + assert (tmp_path / "data" / LIVE_RUNTIME_LOCK_FILENAME).exists() + with live_runtime_lock(tmp_path) as second: + assert second is False + + +def test_run_live_trading_fails_before_workflow_when_runtime_lock_unavailable(monkeypatch): + import core.runtime_lock as runtime_lock + import main as main_mod + + calls = [] + + @contextmanager + def denied_lock(project_root): + calls.append(project_root) + yield False + + monkeypatch.setattr(runtime_lock, "live_runtime_lock", denied_lock) + monkeypatch.setattr( + main_mod, + "_run_live_trading_impl", + lambda args: pytest.fail("lock 없이 live workflow를 실행하면 안 됨"), + ) + + with pytest.raises(SystemExit) as exc: + main_mod.run_live_trading(SimpleNamespace(strategy="scoring", confirm_live=True)) + + assert exc.value.code == 1 + assert len(calls) == 1 + + +def test_live_rebalance_fails_before_workflow_when_runtime_lock_unavailable(monkeypatch): + import core.runtime_lock as runtime_lock + import main as main_mod + + config = SimpleNamespace(trading={"mode": "live"}) + calls = [] + + @contextmanager + def denied_lock(project_root): + calls.append(project_root) + yield False + + monkeypatch.setattr(main_mod.Config, "get", lambda: config) + monkeypatch.setattr(runtime_lock, "live_runtime_lock", denied_lock) + monkeypatch.setattr( + main_mod, + "_run_rebalance_impl", + lambda args: pytest.fail("lock 없이 live rebalance를 실행하면 안 됨"), + ) + + with pytest.raises(SystemExit) as exc: + main_mod.run_rebalance( + SimpleNamespace(basket="test_basket", dry_run=False, confirm_live=True) + ) + + assert exc.value.code == 1 + assert len(calls) == 1 diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 21142ce1..6c891794 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -84,7 +84,7 @@ def test_scheduler_monitoring_runs_without_api(monkeypatch): """장중 모니터링: 포지션 없음 + KIS get_current_price 모킹으로 _run_monitoring 완료.""" from core.scheduler import Scheduler - def fake_get_all(): + def fake_get_all(account_key=None, mode="paper"): return [] monkeypatch.setattr("core.scheduler.get_all_positions", fake_get_all) @@ -118,6 +118,9 @@ def test_live_monitoring_blocks_new_entries_when_broker_sync_fails(monkeypatch): monkeypatch.setattr("core.scheduler.PositionLock", _NoopLock) scheduler = Scheduler(strategy_name="scoring") + monkeypatch.setattr( + scheduler.config, "get_account_no", lambda strategy="": "12345678-01" + ) old_mode = scheduler.config.trading.get("mode") exit_checked = {"value": False} try: @@ -137,6 +140,7 @@ def test_live_monitoring_blocks_new_entries_when_broker_sync_fails(monkeypatch): is_on_cooldown=lambda: False, ) scheduler.discord = MagicMock() + scheduler._log_monitoring_watchlist_preflight = lambda: None scheduler._maybe_recheck_market_regime = lambda: None scheduler._execute_entry_candidates = lambda: pytest.fail( "broker sync failure must block entry execution" @@ -165,6 +169,9 @@ def test_live_monitoring_allows_entries_after_broker_sync_ok(monkeypatch): monkeypatch.setattr("core.scheduler.PositionLock", _NoopLock) scheduler = Scheduler(strategy_name="scoring") + monkeypatch.setattr( + scheduler.config, "get_account_no", lambda strategy="": "12345678-01" + ) old_mode = scheduler.config.trading.get("mode") calls = {"entry": 0, "rescan": 0, "exit": 0} try: @@ -180,6 +187,7 @@ def test_live_monitoring_allows_entries_after_broker_sync_ok(monkeypatch): is_on_cooldown=lambda: False, ) scheduler.discord = MagicMock() + scheduler._log_monitoring_watchlist_preflight = lambda: None scheduler._maybe_recheck_market_regime = lambda: None scheduler._execute_entry_candidates = lambda: calls.__setitem__("entry", calls["entry"] + 1) scheduler._rescan_for_new_entries = lambda: calls.__setitem__("rescan", calls["rescan"] + 1) @@ -195,6 +203,35 @@ def test_live_monitoring_allows_entries_after_broker_sync_ok(monkeypatch): scheduler.config.trading["mode"] = old_mode +def test_monitoring_checks_existing_exits_before_new_entries(monkeypatch): + """급락 첫 사이클에서 기존 노출 점검이 신규 BUY보다 먼저다.""" + from core.scheduler import Scheduler + + monkeypatch.setattr("api.kis_api.KISApi", _FakeKIS) + monkeypatch.setattr("core.scheduler.PositionLock", _NoopLock) + + scheduler = Scheduler(strategy_name="scoring") + calls = [] + scheduler.auto_entry = True + scheduler._entry_candidates = [{"symbol": "005930", "price": 50_000}] + scheduler.blackswan = SimpleNamespace( + consume_cooldown_ended_flag=lambda: False, + is_on_cooldown=lambda: False, + ) + scheduler.discord = MagicMock() + scheduler._log_monitoring_watchlist_preflight = lambda: None + scheduler._maybe_recheck_market_regime = lambda: None + scheduler._check_exit_signals = lambda kis=None: calls.append("exit") + scheduler._execute_entry_candidates = lambda: calls.append("entry") + scheduler._update_dynamic_stop_losses = lambda: None + scheduler._rescan_for_new_entries = lambda: calls.append("rescan") + scheduler._publish_dashboard_runtime_state = lambda kis=None: None + + scheduler._run_monitoring() + + assert calls == ["exit", "entry", "rescan"] + + def test_live_entry_loop_halts_when_order_requires_reconcile(monkeypatch): """live 주문 체결 확인이 보류되면 같은 루프의 남은 신규 진입을 중단한다.""" from core.scheduler import Scheduler @@ -233,7 +270,10 @@ def execute_buy(self, **kwargs): "core.market_regime.check_market_regime", lambda config, collector: {"allow_buys": True, "position_scale": 1.0}, ) - monkeypatch.setattr("core.scheduler.get_position", lambda symbol, account_key="": None) + monkeypatch.setattr( + "core.scheduler.get_position", + lambda symbol, account_key="", mode="paper": None, + ) monkeypatch.setattr( "core.scheduler._log_op", lambda *args, **kwargs: op_events.append((args, kwargs)), @@ -301,7 +341,10 @@ def execute_buy(self, **kwargs): "core.market_regime.check_market_regime", lambda config, collector: {"allow_buys": True, "position_scale": 1.0}, ) - monkeypatch.setattr("core.scheduler.get_position", lambda symbol, account_key="": None) + monkeypatch.setattr( + "core.scheduler.get_position", + lambda symbol, account_key="", mode="paper": None, + ) monkeypatch.setattr( "core.scheduler._log_op", lambda *args, **kwargs: op_events.append((args, kwargs)), @@ -350,6 +393,9 @@ def test_live_monitoring_skips_rescan_after_entry_requires_reconcile(monkeypatch monkeypatch.setattr("core.scheduler.PositionLock", _NoopLock) scheduler = Scheduler(strategy_name="scoring") + monkeypatch.setattr( + scheduler.config, "get_account_no", lambda strategy="": "12345678-01" + ) old_mode = scheduler.config.trading.get("mode") calls = {"entry": 0, "rescan": 0, "exit": 0} try: @@ -365,6 +411,7 @@ def test_live_monitoring_skips_rescan_after_entry_requires_reconcile(monkeypatch is_on_cooldown=lambda: False, ) scheduler.discord = MagicMock() + scheduler._log_monitoring_watchlist_preflight = lambda: None scheduler._maybe_recheck_market_regime = lambda: None def mark_reconcile_block(): @@ -410,7 +457,10 @@ def check_stop_loss_take_profit(self, *args, **kwargs): def execute_sell(self, *args, **kwargs): pytest.fail("invalid price must not submit sell order") - monkeypatch.setattr("core.scheduler.get_all_positions", lambda account_key=None: [position]) + monkeypatch.setattr( + "core.scheduler.get_all_positions", + lambda account_key=None, mode="paper": [position], + ) monkeypatch.setattr( "core.scheduler._log_op", lambda *args, **kwargs: op_events.append((args, kwargs)), @@ -456,7 +506,10 @@ def check_stop_loss_take_profit(self, *args, **kwargs): def execute_sell(self, *args, **kwargs): pytest.fail("missing price must not submit sell order") - monkeypatch.setattr("core.scheduler.get_all_positions", lambda account_key=None: [position]) + monkeypatch.setattr( + "core.scheduler.get_all_positions", + lambda account_key=None, mode="paper": [position], + ) monkeypatch.setattr( "core.scheduler._log_op", lambda *args, **kwargs: op_events.append((args, kwargs)), @@ -480,6 +533,137 @@ def execute_sell(self, *args, **kwargs): scheduler.discord.send_trade_alert.assert_not_called() +def test_blackswan_preempts_gap_exit_and_activates_cooldown(monkeypatch): + """-5% 이하 급락은 gap continue 전에 BlackSwan cooldown을 켜야 한다.""" + from core.blackswan_detector import BlackSwanDetector + from core.scheduler import Scheduler + + position = SimpleNamespace( + symbol="005930", + avg_price=100_000, + quantity=3, + bought_at=None, + ) + + class CrashPriceKIS: + def get_current_price(self, symbol): + return {"price": 94_000, "prev_close": 100_000, "open": 94_000} + + sell_calls = [] + + class FakeExecutor: + def execute_sell(self, symbol, price, **kwargs): + sell_calls.append({"symbol": symbol, "price": price, **kwargs}) + return {"success": True} + + def check_stop_loss_take_profit(self, *args, **kwargs): + pytest.fail("BlackSwan 청산 후 일반 stop 점검으로 진행하면 안 됨") + + detector_config = SimpleNamespace( + risk_params={ + "blackswan": { + "single_stock_threshold": -0.05, + "portfolio_threshold": -0.03, + "consecutive_days": 3, + "consecutive_threshold": -0.02, + "cooldown_minutes": 60, + "recovery_minutes": 120, + "recovery_scale": 0.5, + } + }, + trading={}, + ) + + monkeypatch.setattr( + "core.scheduler.get_all_positions", + lambda account_key=None, mode="paper": [position], + ) + op_events = [] + monkeypatch.setattr( + "core.scheduler._log_op", + lambda *args, **kwargs: op_events.append((args, kwargs)), + ) + + scheduler = Scheduler(strategy_name="scoring") + old_gap = scheduler.config.risk_params.get("gap_risk") + try: + scheduler.config.risk_params["gap_risk"] = { + "enabled": True, + "gap_down_threshold": -0.03, + } + scheduler.blackswan = BlackSwanDetector(detector_config) + scheduler._get_or_create_executor = lambda: FakeExecutor() + scheduler.discord = MagicMock() + + scheduler._check_exit_signals(kis=CrashPriceKIS()) + + assert scheduler.blackswan.is_on_cooldown() is True + assert len(sell_calls) == 1 + assert sell_calls[0]["reason"] == "블랙스완 긴급 매도" + assert op_events and op_events[0][0][0] == "BLACKSWAN" + messages = [call.args[0] for call in scheduler.discord.send_message.call_args_list] + assert any("블랙스완 발동" in message for message in messages) + assert all("갭다운 청산" not in message for message in messages) + finally: + if old_gap is None: + scheduler.config.risk_params.pop("gap_risk", None) + else: + scheduler.config.risk_params["gap_risk"] = old_gap + + +def test_intraday_decline_is_not_misclassified_as_gap_down(monkeypatch): + """시가가 보합이면 장중 -4%를 gap 긴급 청산으로 오인하지 않는다.""" + from core.scheduler import Scheduler + + position = SimpleNamespace( + symbol="005930", + avg_price=100_000, + quantity=3, + bought_at=None, + ) + + class IntradayDropKIS: + def get_current_price(self, symbol): + return { + "price": 96_000, + "prev_close": 100_000, + "open": 100_000, + } + + class FakeExecutor: + def execute_sell(self, *args, **kwargs): + pytest.fail("장중 하락만으로 gap 청산 주문을 내면 안 됨") + + def check_stop_loss_take_profit(self, *args, **kwargs): + return {"action": None} + + monkeypatch.setattr( + "core.scheduler.get_all_positions", + lambda account_key=None, mode="paper": [position], + ) + + scheduler = Scheduler(strategy_name="scoring") + old_gap = scheduler.config.risk_params.get("gap_risk") + try: + scheduler.config.risk_params["gap_risk"] = { + "enabled": True, + "gap_down_threshold": -0.03, + } + scheduler._get_or_create_executor = lambda: FakeExecutor() + scheduler.discord = MagicMock() + + scheduler._check_exit_signals(kis=IntradayDropKIS()) + + assert scheduler.blackswan.is_on_cooldown() is False + scheduler.discord.send_message.assert_not_called() + scheduler.discord.send_trade_alert.assert_not_called() + finally: + if old_gap is None: + scheduler.config.risk_params.pop("gap_risk", None) + else: + scheduler.config.risk_params["gap_risk"] = old_gap + + def test_scheduler_post_market_runs(): """장마감: _run_post_market 호출 시 DB 저장·디스코드 시도만 하고 예외 없이 완료.""" from core.scheduler import Scheduler @@ -489,6 +673,58 @@ def test_scheduler_post_market_runs(): assert True +def test_collect_snapshot_prices_fails_if_any_position_price_is_missing(monkeypatch): + """일부 종목의 가격 누락을 평균단가 폴백으로 숨기지 않는다.""" + from types import SimpleNamespace + + from core.scheduler import Scheduler + + scheduler = Scheduler.__new__(Scheduler) + scheduler.strategy_name = "scoring" + scheduler._ledger_mode = "paper" + scheduler.config = SimpleNamespace(get_account_no=lambda key: "00000000-00") + monkeypatch.setattr( + "core.scheduler.get_all_positions", + lambda account_key=None, mode="paper": [ + SimpleNamespace(symbol="005930"), + SimpleNamespace(symbol="000660"), + ], + ) + + class FakeKIS: + def __init__(self, account_no=None): + self.account_no = account_no + + def get_current_price(self, symbol): + return {"price": 55000} if symbol == "005930" else None + + monkeypatch.setattr("api.kis_api.KISApi", FakeKIS) + + with pytest.raises(RuntimeError, match="000660 현재가"): + scheduler._collect_snapshot_prices() + + +def test_post_market_stops_when_validated_snapshot_cannot_be_saved(): + """스냅샷 실패 뒤 부정확한 일일 리포트를 계속 생성하지 않는다.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + + from core.scheduler import Scheduler + + scheduler = Scheduler.__new__(Scheduler) + scheduler.strategy_name = "scoring" + scheduler.portfolio = MagicMock() + scheduler.portfolio.save_daily_snapshot.return_value = False + scheduler.discord = MagicMock() + scheduler.config = SimpleNamespace(trading={"mode": "paper"}) + scheduler._collect_snapshot_prices = lambda: {"005930": 55000} + + scheduler._run_post_market() + + scheduler.portfolio.get_portfolio_summary.assert_not_called() + scheduler.discord.send_daily_report.assert_not_called() + + def test_scheduler_startup_recovery_paper_mode_no_crash(monkeypatch): """재시작 복구: paper 모드에서 KIS 없이 startup_recovery가 예외 없이 끝난다.""" from core.scheduler import Scheduler @@ -575,7 +811,9 @@ def __init__(self, config=None, account_key="", *, live_gate_validated=False): scheduler._mode = "live" scheduler._live_gate_validated = True - assert scheduler._get_or_create_executor() is scheduler._order_executor + executor = scheduler._get_or_create_executor() + assert executor is scheduler._order_executor + assert executor.blackswan is scheduler.blackswan assert captured == { "account_key": "scoring", "live_gate_validated": True, @@ -760,7 +998,7 @@ def slow_entry(): monkeypatch.setattr(scheduler, "_log_monitoring_watchlist_preflight", lambda: None) monkeypatch.setattr(scheduler, "_maybe_recheck_market_regime", lambda: None) monkeypatch.setattr(scheduler, "_execute_entry_candidates", slow_entry) - monkeypatch.setattr(scheduler, "_check_exit_signals", lambda: None) + monkeypatch.setattr(scheduler, "_check_exit_signals", lambda kis=None: None) monkeypatch.setattr(scheduler, "_update_dynamic_stop_losses", lambda: None) monkeypatch.setattr(scheduler, "_rescan_for_new_entries", lambda: None) monkeypatch.setattr(scheduler, "_publish_dashboard_runtime_state", lambda kis=None: None) diff --git a/tests/test_snapshot_unique_migration.py b/tests/test_snapshot_unique_migration.py index 01791554..bc703c53 100644 --- a/tests/test_snapshot_unique_migration.py +++ b/tests/test_snapshot_unique_migration.py @@ -1,4 +1,4 @@ -"""portfolio_snapshots 구버전 UNIQUE(date) → (account_key, date) 재구축 마이그레이션 테스트. +"""portfolio_snapshots 구버전 UNIQUE → (mode, account_key, date) 재구축 테스트. 배경(2026-07-06 실측): account_key 도입 전 스키마의 date 단독 유니크가 남아 있어, 두 번째 바스켓(kr_pocket)이 같은 날 스냅샷을 저장하는 최초의 순간 IntegrityError로 @@ -33,6 +33,10 @@ def _legacy_engine(tmp_path, n_rows=3): engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") with engine.connect() as conn: conn.execute(text(LEGACY_DDL)) + conn.execute(text( + "CREATE INDEX ix_portfolio_snapshots_account_key " + "ON portfolio_snapshots (account_key)" + )) for i in range(n_rows): conn.execute(text( "INSERT INTO portfolio_snapshots " @@ -43,8 +47,26 @@ def _legacy_engine(tmp_path, n_rows=3): return engine +def _add_trade_modes(engine, rows): + with engine.connect() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS trade_history ( + id INTEGER PRIMARY KEY, + account_key VARCHAR(64) NOT NULL, + symbol VARCHAR(20) NOT NULL, + mode VARCHAR(20) + ) + """)) + for account_key, mode in rows: + conn.execute(text( + "INSERT INTO trade_history (account_key, symbol, mode) " + "VALUES (:account_key, '005930', :mode)" + ), {"account_key": account_key, "mode": mode}) + conn.commit() + + class TestSnapshotUniqueMigration: - def test_rebuild_preserves_rows_and_allows_two_accounts_same_date(self, tmp_path): + def test_rebuild_preserves_rows_and_allows_same_key_in_paper(self, tmp_path): engine = _legacy_engine(tmp_path, n_rows=3) _migrate_snapshot_unique_constraint(engine) @@ -55,18 +77,21 @@ def test_rebuild_preserves_rows_and_allows_two_accounts_same_date(self, tmp_path "SELECT cumulative_return FROM portfolio_snapshots WHERE date = :d" ), {"d": datetime(2026, 7, 2)}).scalar() assert row == pytest.approx(1.0) - # 핵심: 같은 날 다른 계정 저장이 이제 가능해야 한다 + assert conn.execute(text( + "SELECT mode FROM portfolio_snapshots WHERE date = :d" + ), {"d": datetime(2026, 7, 1)}).scalar() == "legacy" + # 같은 계정+날짜여도 paper 장부는 legacy와 독립적으로 저장된다. conn.execute(text( "INSERT INTO portfolio_snapshots (date, total_value, cash, invested, account_key) " - "VALUES (:d, 300000, 150000, 150000, 'basket_rebalance:kr_pocket')" + "VALUES (:d, 300000, 150000, 150000, 'basket_rebalance:kr_x')" ), {"d": datetime(2026, 7, 1)}) conn.commit() assert conn.execute(text("SELECT COUNT(*) FROM portfolio_snapshots")).scalar() == 4 - # 같은 날 같은 계정은 여전히 차단(복합 유니크) + # 같은 mode+계정+날짜는 여전히 차단한다. with pytest.raises(Exception): conn.execute(text( "INSERT INTO portfolio_snapshots (date, total_value, cash, invested, account_key) " - "VALUES (:d, 1, 1, 0, 'basket_rebalance:kr_pocket')" + "VALUES (:d, 1, 1, 0, 'basket_rebalance:kr_x')" ), {"d": datetime(2026, 7, 1)}) def test_idempotent_on_new_schema(self, tmp_path): @@ -78,7 +103,8 @@ def test_idempotent_on_new_schema(self, tmp_path): ddl = conn.execute(text( "SELECT sql FROM sqlite_master WHERE type='table' AND name='portfolio_snapshots'" )).scalar() - assert "uq_snapshots_account_date" in ddl + assert "uq_snapshots_mode_account_date" in ddl + assert "mode VARCHAR(20) DEFAULT 'paper' NOT NULL" in ddl def test_resume_after_interrupted_rename(self, tmp_path): # rename 직후 중단된 상태(legacy 테이블 존재 + 본 테이블 부재)에서 재개 @@ -95,3 +121,36 @@ def test_resume_after_interrupted_rename(self, tmp_path): "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='portfolio_snapshots_legacy_uq'" )).scalar() assert legacy == 0 + indexed_table = conn.execute(text( + "SELECT tbl_name FROM sqlite_master WHERE type='index' " + "AND name='ix_portfolio_snapshots_account_key'" + )).scalar() + assert indexed_table == "portfolio_snapshots" + + def test_single_account_trade_mode_is_inferred(self, tmp_path): + engine = _legacy_engine(tmp_path, n_rows=1) + _add_trade_modes(engine, [ + ("basket_rebalance:kr_x", "LIVE"), + ("basket_rebalance:kr_x", "live"), + ]) + + _migrate_snapshot_unique_constraint(engine) + + with engine.connect() as conn: + assert conn.execute(text( + "SELECT mode FROM portfolio_snapshots" + )).scalar() == "live" + + def test_mixed_account_trade_modes_are_quarantined(self, tmp_path): + engine = _legacy_engine(tmp_path, n_rows=1) + _add_trade_modes(engine, [ + ("basket_rebalance:kr_x", "paper"), + ("basket_rebalance:kr_x", "live"), + ]) + + _migrate_snapshot_unique_constraint(engine) + + with engine.connect() as conn: + assert conn.execute(text( + "SELECT mode FROM portfolio_snapshots" + )).scalar() == "legacy" diff --git a/tests/test_target_weight_paper_adapter.py b/tests/test_target_weight_paper_adapter.py index 1200da8e..d7d76911 100644 --- a/tests/test_target_weight_paper_adapter.py +++ b/tests/test_target_weight_paper_adapter.py @@ -1043,6 +1043,7 @@ def __init__( self.commission_rate = commission_rate self.slippage_per_share = slippage_per_share self.tax_rate = tax_rate + self.calls = [] self.risk_params = { "diversification": { "max_position_ratio": max_position_ratio, @@ -1059,7 +1060,13 @@ def calculate_transaction_costs( action="BUY", avg_daily_volume=None, avg_price=None, + symbol=None, ): + self.calls.append({ + "action": action, + "symbol": symbol, + "quantity": quantity, + }) commission = round(price * quantity * self.commission_rate, 0) tax = round(price * quantity * self.tax_rate, 0) if action.upper() == "SELL" else 0 slippage = round(self.slippage_per_share * quantity, 0) @@ -10506,12 +10513,13 @@ def test_assess_plan_pre_trade_risk_passes_when_cash_covers_costed_orders(): from tools.target_weight_rotation_pilot import assess_plan_pre_trade_risk plan = _adapter_plan() + risk_manager = SimpleCostRiskManager( + commission_rate=0.001, + slippage_per_share=1.0, + ) risk = assess_plan_pre_trade_risk( plan, - risk_manager=SimpleCostRiskManager( - commission_rate=0.001, - slippage_per_share=1.0, - ), + risk_manager=risk_manager, ) assert risk["complete"] is True @@ -10520,6 +10528,9 @@ def test_assess_plan_pre_trade_risk_passes_when_cash_covers_costed_orders(): assert risk["cost_summary"]["commission"] > 0 assert risk["cost_summary"]["slippage"] > 0 assert risk["order_costs"][0]["avg_daily_volume"] == 1_000_000.0 + assert [call["symbol"] for call in risk_manager.calls] == [ + order.symbol for order in plan.orders + ] def test_assess_plan_pre_trade_risk_blocks_cash_shortfall_after_costs(): @@ -13768,6 +13779,7 @@ def capture_daily_evidence(**kwargs): promotion_dir=promotion_dir, evidence_dir=evidence_dir, current_git_hash="abc123", + current_git_worktree_state=(True, ""), now=datetime(2026, 4, 10, 12, 0, 0), ) diff --git a/tests/test_target_weight_rotation.py b/tests/test_target_weight_rotation.py index 47fe1bfb..16dcb22a 100644 --- a/tests/test_target_weight_rotation.py +++ b/tests/test_target_weight_rotation.py @@ -17,7 +17,10 @@ def fetch_korean_stock(self, symbol, start_date=None, end_date=None): class NoCostRiskManager: - def calculate_transaction_costs(self, price, quantity, action="BUY", avg_daily_volume=None, avg_price=None): + def calculate_transaction_costs( + self, price, quantity, action="BUY", avg_daily_volume=None, + avg_price=None, symbol=None, + ): return { "commission": 0.0, "tax": 0.0, @@ -28,7 +31,10 @@ def calculate_transaction_costs(self, price, quantity, action="BUY", avg_daily_v class CostRiskManager: - def calculate_transaction_costs(self, price, quantity, action="BUY", avg_daily_volume=None, avg_price=None): + def calculate_transaction_costs( + self, price, quantity, action="BUY", avg_daily_volume=None, + avg_price=None, symbol=None, + ): if action == "BUY": return { "commission": 1.0, @@ -50,13 +56,17 @@ class RecordingSlippageRiskManager: def __init__(self): self.calls = [] - def calculate_transaction_costs(self, price, quantity, action="BUY", avg_daily_volume=None, avg_price=None): + def calculate_transaction_costs( + self, price, quantity, action="BUY", avg_daily_volume=None, + avg_price=None, symbol=None, + ): self.calls.append( { "action": action, "avg_daily_volume": avg_daily_volume, "quantity": quantity, "avg_price": avg_price, + "symbol": symbol, } ) multiplier = 2.0 if avg_daily_volume else 1.0 @@ -987,6 +997,7 @@ def test_target_weight_rotation_passes_avg_daily_volume_to_transaction_costs(): assert recorder.calls assert any(call["action"] == "SELL" for call in recorder.calls) assert all(call["avg_daily_volume"] == pytest.approx(1_000_000.0) for call in recorder.calls) + assert all(call["symbol"] in {"AAA", "BBB", "CCC"} for call in recorder.calls) trades = result["trades"] metrics = result["target_weight_metrics"] diff --git a/tests/test_trading_halt.py b/tests/test_trading_halt.py new file mode 100644 index 00000000..feb40e11 --- /dev/null +++ b/tests/test_trading_halt.py @@ -0,0 +1,292 @@ +"""전역 거래 HALT 킬스위치 회귀 테스트.""" + +import json +from types import SimpleNamespace + +import pytest + +from config.config_loader import Config + + +@pytest.fixture(autouse=True) +def isolated_trading_halt_state(): + """각 테스트가 append-only HALT 상태를 다음 테스트에 유출하지 않게 한다.""" + from database.models import ( + FailedOrder, + OperationEvent, + OrderRecord, + PendingOrderGuard, + Position, + TradeHistory, + get_session, + init_database, + ) + + Config._instance = None + init_database() + models = ( + OperationEvent, + OrderRecord, + PendingOrderGuard, + FailedOrder, + TradeHistory, + Position, + ) + + def clean(): + session = get_session() + try: + for model in models: + session.query(model).delete() + session.commit() + finally: + session.close() + + clean() + yield + clean() + Config._instance = None + + +def test_trading_halt_repository_is_append_only_and_requires_confirmed_clear(): + from database.models import OperationEvent, get_session + from database.repositories import ( + TRADING_HALT_CLEARED, + TRADING_HALT_SET, + TRADING_HALT_STRATEGY, + clear_trading_halt, + get_trading_halt_state, + set_trading_halt, + ) + + assert get_trading_halt_state()["halted"] is False + + halted = set_trading_halt( + "증권사 장애로 긴급 청산", + source="test.operator", + mode="live", + detail={"ticket": "INC-42"}, + ) + assert halted["halted"] is True + assert halted["reason"] == "증권사 장애로 긴급 청산" + assert halted["source"] == "test.operator" + + with pytest.raises(ValueError, match="confirmed=True"): + clear_trading_halt("확인 없는 해제") + assert get_trading_halt_state()["halted"] is True + + cleared = clear_trading_halt( + "잔고와 주문 상태 대조 완료", + source="test.operator", + mode="live", + confirmed=True, + expected_active_event_id=halted["event_id"], + ) + assert cleared["halted"] is False + assert get_trading_halt_state()["event_id"] == cleared["event_id"] + + session = get_session() + try: + events = ( + session.query(OperationEvent) + .filter(OperationEvent.strategy == TRADING_HALT_STRATEGY) + .order_by(OperationEvent.id.asc()) + .all() + ) + assert [event.event_type for event in events] == [ + TRADING_HALT_SET, + TRADING_HALT_CLEARED, + ] + assert [event.severity for event in events] == ["critical", "warning"] + set_detail = json.loads(events[0].detail) + clear_detail = json.loads(events[1].detail) + assert set_detail == { + "ticket": "INC-42", + "reason": "증권사 장애로 긴급 청산", + "source": "test.operator", + "global": True, + } + assert clear_detail["confirmed"] is True + assert clear_detail["reason"] == "잔고와 주문 상태 대조 완료" + finally: + session.close() + + +def test_active_halt_blocks_both_public_buy_paths_and_records_audit_events(): + from core.order_executor import OrderExecutor + from database.models import OperationEvent, get_session + from database.repositories import set_trading_halt + + halt = set_trading_halt( + "긴급 청산 진행 중", + source="test.operator", + mode="live", + ) + executor = OrderExecutor(account_key="halt_buy_test") + + sized = executor.execute_buy( + symbol="005930", + price=60_000, + capital=10_000_000, + available_cash=10_000_000, + reason="HALT 회귀 테스트", + strategy="scoring", + ) + fixed = executor.execute_buy_quantity( + symbol="000660", + price=120_000, + quantity=1, + capital=10_000_000, + available_cash=10_000_000, + reason="HALT 회귀 테스트", + strategy="target_weight_rotation", + ) + + for result in (sized, fixed): + assert result["success"] is False + assert result["trading_halt_blocked"] is True + assert result["trading_halt_check_failed"] is False + assert result["trading_halt_state"]["event_id"] == halt["event_id"] + assert "긴급 청산 진행 중" in result["reason"] + + session = get_session() + try: + blocked = session.query(OperationEvent).filter( + OperationEvent.event_type == "TRADING_HALT_BUY_BLOCKED" + ).all() + assert len(blocked) == 2 + assert {event.symbol for event in blocked} == {"005930", "000660"} + assert all(event.severity == "critical" for event in blocked) + finally: + session.close() + + +def test_buy_paths_fail_closed_when_halt_state_lookup_fails(monkeypatch): + import core.order_executor as executor_module + from database.models import OperationEvent, get_session + + executor = executor_module.OrderExecutor(account_key="halt_lookup_failure_test") + + def fail_lookup(): + raise RuntimeError("HALT DB unavailable") + + monkeypatch.setattr(executor_module, "get_trading_halt_state", fail_lookup) + + sized = executor.execute_buy( + symbol="005930", + price=60_000, + capital=10_000_000, + available_cash=10_000_000, + strategy="scoring", + ) + fixed = executor.execute_buy_quantity( + symbol="000660", + price=120_000, + quantity=1, + capital=10_000_000, + available_cash=10_000_000, + strategy="target_weight_rotation", + ) + + for result in (sized, fixed): + assert result["success"] is False + assert result["trading_halt_blocked"] is True + assert result["trading_halt_check_failed"] is True + assert "fail-closed" in result["reason"] + + session = get_session() + try: + failures = session.query(OperationEvent).filter( + OperationEvent.event_type == "TRADING_HALT_CHECK_FAILED" + ).all() + assert len(failures) == 2 + assert all(event.severity == "critical" for event in failures) + finally: + session.close() + + +def test_sell_bypasses_halt_lookup_and_remains_available(monkeypatch): + import core.order_executor as executor_module + from database.repositories import get_position, save_position, set_trading_halt + + set_trading_halt( + "청산 전용 상태", + source="test.operator", + mode="live", + ) + executor = executor_module.OrderExecutor(account_key="halt_sell_test") + executor.config.risk_params["position_limits"]["min_holding_days"] = 0 + save_position( + symbol="005930", + avg_price=60_000, + quantity=1, + strategy="scoring", + account_key="halt_sell_test", + ) + + def lookup_must_not_run(): + raise AssertionError("SELL은 HALT DB를 조회하면 안 됩니다") + + monkeypatch.setattr(executor_module, "get_trading_halt_state", lookup_must_not_run) + result = executor.execute_sell( + symbol="005930", + price=59_000, + quantity=1, + reason="긴급 전량 청산", + strategy="emergency_liquidate", + ) + + assert result["success"] is True + assert get_position("005930", account_key="halt_sell_test") is None + + +def test_clear_halt_cli_requires_confirmation_and_persists_operator_audit(): + from database.repositories import get_trading_halt_state, set_trading_halt + from tools.clear_trading_halt import main as clear_halt_main + + original = set_trading_halt( + "운영자 긴급 청산", + source="main.run_emergency_liquidate", + mode="live", + ) + + assert clear_halt_main(["--reason", "잔고 대조 완료"]) == 2 + assert get_trading_halt_state()["event_id"] == original["event_id"] + assert get_trading_halt_state()["halted"] is True + + assert clear_halt_main([ + "--confirm", + "--reason", + "잔고·미체결 수동 대조 완료", + ]) == 0 + cleared = get_trading_halt_state() + assert cleared["halted"] is False + assert cleared["source"] == "tools.clear_trading_halt" + assert cleared["detail"]["confirmed"] is True + assert cleared["detail"]["previous_event_id"] == original["event_id"] + + +def test_live_liquidation_aborts_before_broker_sync_if_halt_cannot_persist(monkeypatch): + import database.repositories as repositories + import main as main_module + + config = SimpleNamespace(trading={"mode": "live"}) + calls = [] + monkeypatch.setattr(main_module.Config, "get", lambda: config) + monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") + + def fail_halt_write(*args, **kwargs): + calls.append("halt") + raise RuntimeError("HALT persistence failed") + + monkeypatch.setattr(repositories, "set_trading_halt", fail_halt_write) + monkeypatch.setattr( + main_module, + "_sync_live_positions_before_liquidation", + lambda cfg: calls.append("broker_sync"), + ) + + with pytest.raises(RuntimeError, match="HALT persistence failed"): + main_module.run_emergency_liquidate(SimpleNamespace(confirm_live=True)) + + assert calls == ["halt"] diff --git a/tests/test_update_stop_loss_price_shim.py b/tests/test_update_stop_loss_price_shim.py index bbba1434..4aedf061 100644 --- a/tests/test_update_stop_loss_price_shim.py +++ b/tests/test_update_stop_loss_price_shim.py @@ -25,13 +25,14 @@ def test_update_stop_loss_price_delegates_to_update_position_targets(monkeypatch def fake_update_position_targets( symbol, stop_loss_price=None, take_profit_price=None, - trailing_stop_price=None, account_key="", + trailing_stop_price=None, account_key="", mode="paper", ): captured["symbol"] = symbol captured["stop_loss_price"] = stop_loss_price captured["take_profit_price"] = take_profit_price captured["trailing_stop_price"] = trailing_stop_price captured["account_key"] = account_key + captured["mode"] = mode monkeypatch.setattr(repo, "update_position_targets", fake_update_position_targets) @@ -43,6 +44,7 @@ def fake_update_position_targets( "take_profit_price": None, "trailing_stop_price": None, "account_key": "scoring", + "mode": "paper", } diff --git a/tools/clear_trading_halt.py b/tools/clear_trading_halt.py new file mode 100644 index 00000000..4142c362 --- /dev/null +++ b/tools/clear_trading_halt.py @@ -0,0 +1,85 @@ +r"""전역 거래 HALT 명시적 운영자 해제 도구. + +사용: + .venv\Scripts\python.exe tools/clear_trading_halt.py \ + --confirm --reason "증권사 장애 해소 및 잔고 대조 완료" + +HALT는 긴급 청산 후 자동 해제되지 않는다. 이 도구는 --confirm과 +빈 값이 아닌 --reason을 모두 요구하며, 해제 사유와 이전 HALT를 +OperationEvent append-only 감사 로그에 남긴다. +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from loguru import logger # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="전역 거래 HALT 해제 (명시적 운영자 확인 필수)", + ) + parser.add_argument( + "--confirm", + action="store_true", + help="BUY 차단 해제의 위험을 확인한다", + ) + parser.add_argument( + "--reason", + required=True, + help="해제 근거(장애 해소, 잔고/미체결 대조 등)", + ) + args = parser.parse_args(argv) + + reason = str(args.reason or "").strip() + if not args.confirm: + logger.critical("HALT 해제 거부: --confirm이 필요합니다") + return 2 + if not reason: + logger.critical("HALT 해제 거부: --reason은 빈 값일 수 없습니다") + return 2 + + from database.models import init_database + from database.repositories import clear_trading_halt, get_trading_halt_state + + init_database() + try: + previous = get_trading_halt_state() + cleared = clear_trading_halt( + reason, + source="tools.clear_trading_halt", + mode="live", + confirmed=True, + expected_active_event_id=previous.get("event_id"), + detail={ + "previous_halted": bool(previous.get("halted", False)), + "previous_event_id": previous.get("event_id"), + "previous_reason": previous.get("reason", ""), + }, + ) + verified = get_trading_halt_state() + except Exception as exc: + logger.exception("HALT 해제 실패 — BUY 차단 상태를 유지하세요: {}", exc) + return 1 + + if verified.get("halted", True): + logger.critical( + "HALT 해제 검증 실패 (event_id={}) — BUY 차단 유지", + verified.get("event_id"), + ) + return 1 + + logger.warning( + "전역 거래 HALT 해제 완료 (event_id={}, previous_event_id={}) reason={}", + cleared.get("event_id"), + previous.get("event_id"), + reason, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/migrate_basket_paper_account.py b/tools/migrate_basket_paper_account.py index 565718d1..f159515b 100644 --- a/tools/migrate_basket_paper_account.py +++ b/tools/migrate_basket_paper_account.py @@ -52,13 +52,19 @@ def main() -> int: ) positions = ( session.query(Position) - .filter(Position.account_key == "") - .filter(Position.strategy == "basket_rebalance") + .filter( + Position.mode == "paper", + Position.account_key == "", + Position.strategy == "basket_rebalance", + ) .all() ) snaps = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == "") + .filter( + PortfolioSnapshot.mode == "paper", + PortfolioSnapshot.account_key == "", + ) .all() ) orders = ( diff --git a/tools/record_deposit.py b/tools/record_deposit.py index eb124b5e..13517df0 100644 --- a/tools/record_deposit.py +++ b/tools/record_deposit.py @@ -19,7 +19,13 @@ from loguru import logger # noqa: E402 -def record_basket_deposit(basket_name: str, amount: float, note: str = "") -> dict: +def record_basket_deposit( + basket_name: str, + amount: float, + note: str = "", + *, + request_id: str | None = None, +) -> dict: """바스켓 적립 입금 기록 — CLI와 웹 대시보드가 공유하는 단일 검증·기록 경로. 검증: 금액 양수, 바스켓 존재, 과거 소급 금지(마지막 스냅샷 이후만 — TWR 체인 보호). @@ -58,20 +64,24 @@ def record_basket_deposit(basket_name: str, amount: float, note: str = "") -> di return {"ok": False, "error": f"바스켓 '{basket_name}' 설정 없음"} account_key = rebalance_live_strategy_id(basket_name) basket_capital = (baskets.get(basket_name) or {}).get("initial_capital") + mode = ( + "live" + if str(config.trading.get("mode", "paper")).lower() == "live" + else "paper" + ) now = datetime.now() - prev = get_latest_snapshot_summary(account_key=account_key) + prev = get_latest_snapshot_summary(account_key=account_key, mode=mode) if prev is not None: boundary = prev.get("created_at") or prev.get("date") if boundary is not None and now <= boundary: return {"ok": False, "error": "마지막 스냅샷 이전 시각 — 과거 소급 기록 불가"} - mode = str(config.trading.get("mode", "paper")).lower() flow_id = record_cash_flow( amount=amount, account_key=account_key, occurred_at=now, - note=note or "", mode=mode, + note=note or "", mode=mode, request_id=request_id, ) - deposits = get_cash_flow_total(account_key=account_key) + deposits = get_cash_flow_total(account_key=account_key, mode=mode) out = { "ok": True, "account_key": account_key, "flow_id": int(flow_id), "amount": amount, "deposits_total": float(deposits), @@ -128,11 +138,16 @@ def main() -> int: init_database() config = Config.get() account_key = args.account_key + mode = ( + "live" + if str(config.trading.get("mode", "paper")).lower() == "live" + else "paper" + ) now = datetime.now() # 과거 소급 금지: 마지막 스냅샷 이전 시각의 입금은 TWR 체인이 중화하지 못해 # (그 구간 수익률이 이미 확정됨) 수익률이 왜곡된다 — fail-closed. - prev = get_latest_snapshot_summary(account_key=account_key) + prev = get_latest_snapshot_summary(account_key=account_key, mode=mode) if prev is not None: boundary = prev.get("created_at") or prev.get("date") if boundary is not None and now <= boundary: @@ -142,12 +157,11 @@ def main() -> int: ) return 1 - mode = str(config.trading.get("mode", "paper")).lower() flow_id = record_cash_flow( amount=float(args.amount), account_key=account_key, occurred_at=now, note=args.note, mode=mode, ) - deposits = get_cash_flow_total(account_key=account_key) + deposits = get_cash_flow_total(account_key=account_key, mode=mode) logger.info( "입금 기록 완료 (id={}): {} +{:,.0f}원 — 누적 입금 {:,.0f}원", flow_id, account_key, args.amount, deposits, diff --git a/tools/research_candidate_sweep.py b/tools/research_candidate_sweep.py index 3ea8cf2b..ecd92077 100644 --- a/tools/research_candidate_sweep.py +++ b/tools/research_candidate_sweep.py @@ -3396,6 +3396,7 @@ def _execute_target_weight_rebalance( "SELL", avg_daily_volume=avg_daily_volume, avg_price=avg_price, + symbol=sym, ) execution_price = float(costs["execution_price"]) tax = float(costs.get("tax", 0) or 0) + float(costs.get("capital_gains_tax", 0) or 0) @@ -3447,6 +3448,7 @@ def _execute_target_weight_rebalance( qty_to_buy, "BUY", avg_daily_volume=avg_daily_volume, + symbol=sym, ) outlay = float(costs["execution_price"]) * qty_to_buy + float(costs.get("commission", 0) or 0) buy_plans.append((sym, qty_to_buy, costs, outlay, avg_daily_volume)) @@ -3465,6 +3467,7 @@ def _execute_target_weight_rebalance( qty, "BUY", avg_daily_volume=avg_daily_volume, + symbol=sym, ) execution_price = float(costs["execution_price"]) commission = float(costs.get("commission", 0) or 0) diff --git a/tools/restart_basket_track_record.py b/tools/restart_basket_track_record.py index f660684a..a7841bd1 100644 --- a/tools/restart_basket_track_record.py +++ b/tools/restart_basket_track_record.py @@ -65,7 +65,10 @@ def main() -> int: if args.undo: existing = ( session.query(TradeHistory) - .filter(TradeHistory.account_key == dst_key) + .filter( + TradeHistory.mode == "paper", + TradeHistory.account_key == dst_key, + ) .count() ) if existing: @@ -85,7 +88,10 @@ def main() -> int: ]: rows = ( session.query(model) - .filter(model.account_key == src_key) + .filter( + model.mode == "paper", + model.account_key == src_key, + ) .all() ) moves.append((model.__tablename__, rows, has_strategy)) diff --git a/tools/target_weight_rotation_pilot.py b/tools/target_weight_rotation_pilot.py index a9a729d4..331ed04f 100644 --- a/tools/target_weight_rotation_pilot.py +++ b/tools/target_weight_rotation_pilot.py @@ -550,7 +550,10 @@ def _load_symbols(config: Any, raw_symbols: str | None) -> list[str]: def _load_positions(account_key: str) -> dict[str, Any]: from database.repositories import get_all_positions - return {pos.symbol: pos for pos in get_all_positions(account_key=account_key)} + return { + pos.symbol: pos + for pos in get_all_positions(account_key=account_key, mode="paper") + } def _portfolio_cash(config: Any, account_key: str, cash_override: float | None) -> float: @@ -2972,7 +2975,10 @@ def _target_weight_snapshot_database_state( day_end = day_start + timedelta(days=1) snapshots = ( session.query(PortfolioSnapshot) - .filter(PortfolioSnapshot.account_key == ak) + .filter( + PortfolioSnapshot.mode == "paper", + PortfolioSnapshot.account_key == ak, + ) .order_by(PortfolioSnapshot.date.desc()) .all() ) @@ -3001,7 +3007,10 @@ def _target_weight_snapshot_database_state( ) positions_total = ( session.query(Position) - .filter(Position.account_key == ak) + .filter( + Position.mode == "paper", + Position.account_key == ak, + ) .count() ) return { @@ -6996,7 +7005,10 @@ def backup_target_weight_db_restore_state( ) positions = ( session.query(Position) - .filter(Position.account_key == candidate_id) + .filter( + Position.mode == "paper", + Position.account_key == candidate_id, + ) .order_by(Position.symbol.asc()) .all() ) @@ -7427,7 +7439,10 @@ def apply_target_weight_db_restore_plan( ) existing_positions = ( session.query(Position) - .filter(Position.account_key == candidate_id) + .filter( + Position.mode == "paper", + Position.account_key == candidate_id, + ) .count() ) db_precondition = { @@ -7472,6 +7487,7 @@ def apply_target_weight_db_restore_plan( for row in position_rows: avg_price = float(row.get("avg_price")) position = Position( + mode="paper", account_key=candidate_id, symbol=normalize_symbol(str(row.get("symbol") or "")), avg_price=avg_price, @@ -9717,6 +9733,7 @@ def assess_plan_pre_trade_risk( action, avg_daily_volume=avg_daily_volume, avg_price=avg_price, + symbol=order.symbol, ) commission = float(costs.get("commission", 0) or 0) tax = float(costs.get("tax", 0) or 0)