diff --git a/config/holidays.yaml b/config/holidays.yaml index 431565ee..354a29d3 100644 --- a/config/holidays.yaml +++ b/config/holidays.yaml @@ -1,17 +1,33 @@ # 한국 증시 휴장일 — 자동 갱신 (pykrx + fallback). 수동 편집 가능. # 갱신: python main.py --update-holidays +# +# 2026-08-26 달력 전면 교정 (tools/verify_trading_calendar.py 가 시장 데이터와 대조해 적발): +# 추가: 02-16·02-17·02-18(설 연휴 — 2025년 날짜인 01-27~29가 대신 들어가 있었다), +# 05-01(근로자의 날), 06-03(지방선거), 07-17 +# 제거: 01-27·01-28·01-29 — 이 날들은 실제로 개장했다 +# 07-17이 빠져 있어 거래일로 판정됐고, 그만큼 +# 07-17이 빠져 있어 거래일로 판정됐고, 그만큼 +# 스냅샷 커버리지 분모가 부풀려져 두 트랙이 실제보다 낮게 나왔다 +# (kr_diversified_hold 96.3% → 94.5%, kr_pocket 96.9% → 93.9%). +# 근거: KOSPI 지수와 005930·105560·069500 전부 그날 봉이 없고 07-16·07-20은 있다. +# pykrx 영업일 API는 이 환경에서 깨져 있어(RangeIndex) 자동 갱신이 놓쳤다. +# 달력 오류는 양방향으로 조용히 해롭다 — 분모를 부풀리거나, 진짜 결측을 가린다. +# tools/verify_trading_calendar.py 로 시장 데이터와 대조할 것. holidays: - '2026-01-01' -- '2026-01-27' -- '2026-01-28' -- '2026-01-29' +- '2026-02-16' +- '2026-02-17' +- '2026-02-18' - '2026-03-01' - '2026-03-02' +- '2026-05-01' - '2026-05-05' - '2026-05-24' - '2026-05-25' +- '2026-06-03' - '2026-06-06' +- '2026-07-17' - '2026-08-15' - '2026-08-17' - '2026-09-24' diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 66036a2e..59f812a9 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -40,6 +40,7 @@ def evaluate_basket_paper_operation( trading_days_total: int, snapshot_days: int, pending_failed_orders: int, + reconstructed_days: int = 0, total_costs: float, initial_capital: float, nav_return_pct: float | None = None, @@ -55,6 +56,8 @@ def evaluate_basket_paper_operation( """ trading_days_total = max(int(trading_days_total), 0) snapshot_days = max(int(snapshot_days), 0) + reconstructed_days = max(0, min(int(reconstructed_days), snapshot_days)) + measured_days = snapshot_days - reconstructed_days issues: list[str] = [] # A. 운영 무결성 @@ -92,6 +95,11 @@ def evaluate_basket_paper_operation( "today": str(today), "progress_days": trading_days_total, "snapshot_days": snapshot_days, + "reconstructed_days": reconstructed_days, + "measured_days": measured_days, + "measured_coverage": round( + (measured_days / trading_days_total) if trading_days_total > 0 else 0.0, 4 + ), "min_trading_days": min_trading_days, "progress_pct": min(1.0, trading_days_total / min_trading_days) if min_trading_days > 0 else 1.0, "snapshot_coverage": round(coverage, 4), @@ -122,7 +130,12 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s f" {result['progress_pct']:.0%})" if result["verdict"] == "WAIT" else "" ), f" 운영 기간: {result['operation_start']} ~ {result['today']}", - f" 스냅샷 커버리지: {result['snapshot_coverage']:.0%}", + f" 스냅샷 커버리지: {result['snapshot_coverage']:.0%}" + + ( + f" (실측 {result['measured_days']}일 {result['measured_coverage']:.0%}" + f" + 사후 복원 {result['reconstructed_days']}일)" + if result.get("reconstructed_days") else "" + ), f" 비용 드래그: 누적 {result['cost_drag_cum']:.4%} | 연환산 {result['cost_drag_annualized']:.4%}" + ( " (기간 미충족 — 초기 매입 일회성 비용이 과장되므로 판정 미적용)" @@ -414,8 +427,14 @@ def _d(v): th = TradingHours(config) snapshot_dates = {_d(s.date) for s in snaps} + # 사후 복원(reconstructed)된 날은 따로 센다. 커버리지 95% 게이트는 '시스템이 실제로 + # 돌았는가'를 보는 장치라, 보정분이 실측인 척하면 게이트가 목적을 잃는다. + reconstructed_dates = { + _d(s.date) for s in snaps if bool(getattr(s, "reconstructed", False)) + } trading_days_total = 0 snapshot_days = 0 + reconstructed_days = 0 d = operation_start while d <= today: if th.is_trading_day(datetime(d.year, d.month, d.day)): @@ -426,6 +445,8 @@ def _d(v): trading_days_total += 1 if d in snapshot_dates: snapshot_days += 1 + if d in reconstructed_dates: + reconstructed_days += 1 d += timedelta(days=1) total_costs = sum( @@ -487,6 +508,7 @@ def _d(v): today=today, trading_days_total=trading_days_total, snapshot_days=snapshot_days, + reconstructed_days=reconstructed_days, # dead-letter도 이 바스켓 계정 것만 집계 — 다른 전략의 잔여 실패 주문이 # 바스켓 승격을 막는 오판 방지(바스켓 자신의 실패는 여전히 fail-closed). pending_failed_orders=len(get_pending_failed_orders(account_key=basket_key) or []), diff --git a/core/snapshot_backfill.py b/core/snapshot_backfill.py new file mode 100644 index 00000000..ed37a475 --- /dev/null +++ b/core/snapshot_backfill.py @@ -0,0 +1,304 @@ +"""결측 NAV 스냅샷 복원 — 원장 재생 + 과거 시세로 빠진 날을 되살린다. + +왜 필요한가: +일일 사이클이 하루 안 돌면 그 영업일 스냅샷은 영구 결측이 된다. `_nav_attribution_date`가 +'오늘이 거래일이면 오늘'로 귀속하기 때문에, 다음날 실행은 어제를 메우지 못하고 자기 날짜로만 +저장한다(2026-08-18 결측이 8/19~8/26 내내 그대로 남은 이유). 승격 게이트의 스냅샷 커버리지 +기준이 95%라 결측 며칠이 판정을 뒤집는다. + +무엇을 복원할 수 있고 무엇은 못 하나: + 포지션·현금 원장(trade_history + cash_flows) 재생으로 **정확히** 복원된다. + 실측 81일치를 재구성해 대조한 결과 현금은 전부 일치했다. + NAV 정확히는 복원 못 한다. 기존 스냅샷은 10:07 장중 마크인데 그 시점의 틱은 + 남아 있지 않다. 일별 OHLC로 근사할 뿐이다. 실측 대비 오차는 + 시가+종가 중간값 기준 평균 0.45~0.53%, 최대 1.9%였다(시가 0.6%, 종가 0.8%). + 그래서 중간값을 쓴다. + +그래서 복원분은 `reconstructed=True`로 표시하고 평가가 실측과 나눠 표기한다. +커버리지 95% 게이트는 '시스템이 실제로 돌았는가'를 보는 장치이므로, 보정분이 실측인 척하면 +게이트가 목적을 잃는다. 채우되, 무엇이 채워진 것인지 항상 보이게 한다. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any + +from loguru import logger + + +def _as_date(v: Any) -> date: + return v.date() if isinstance(v, datetime) else v + + +def replay_ledger( + account_key: str, + as_of: date, + initial_capital: float, + mode: str = "paper", +) -> dict[str, Any]: + """as_of 장마감 시점의 포지션·현금·투자원금을 원장 재생으로 구한다. + + 반환: {"positions": {symbol: qty}, "cost_basis": {symbol: 원금}, "cash": float} + 거래가 없던 구간이면 앞뒤 스냅샷과 정확히 일치한다(검증됨). + """ + from database.models import CashFlow, TradeHistory, get_session + + boundary = datetime.combine(_as_date(as_of), datetime.max.time()) + session = get_session() + try: + trades = ( + session.query(TradeHistory) + .filter( + TradeHistory.mode == mode, + TradeHistory.account_key == account_key, + TradeHistory.executed_at <= boundary, + ) + .order_by(TradeHistory.id.asc()) + .all() + ) + flows = ( + session.query(CashFlow) + .filter( + CashFlow.mode == mode, + CashFlow.account_key == account_key, + CashFlow.occurred_at <= boundary, + ) + .all() + ) + rows = [ + (t.symbol, str(t.action).upper(), int(t.quantity or 0), + float(t.total_amount or 0), float(t.commission or 0) + float(t.tax or 0)) + for t in trades + ] + flow_total = sum(float(f.amount or 0) for f in flows) + finally: + session.close() + + cash = float(initial_capital) + flow_total + qty: dict[str, int] = {} + cost: dict[str, float] = {} + for symbol, action, q, amount, fee in rows: + if action == "BUY": + qty[symbol] = qty.get(symbol, 0) + q + cost[symbol] = cost.get(symbol, 0.0) + amount + cash -= amount + fee + else: + held = qty.get(symbol, 0) + # 매도분만큼 원가를 비례 차감한다(평균단가 유지). + if held > 0: + cost[symbol] = cost.get(symbol, 0.0) * max(0, held - q) / held + qty[symbol] = held - q + cash += amount - fee + + positions = {s: q for s, q in qty.items() if q > 0} + return { + "positions": positions, + "cost_basis": {s: round(cost.get(s, 0.0), 0) for s in positions}, + "cash": cash, + } + + +def historical_mark(symbol: str, day: date, collector: Any = None) -> float | None: + """그날의 평가 기준가 — 시가와 종가의 중간값. + + 기존 스냅샷이 10:07 장중 마크라 종가로 재구성하면 계통 오차가 커진다(평균 0.8%). + 중간값이 실측에 가장 가까웠다(평균 0.45~0.53%). 해당 일자 데이터가 없으면 None. + """ + try: + import FinanceDataReader as fdr + import pandas as pd + + target = _as_date(day) + df = fdr.DataReader( + symbol, + (target - timedelta(days=10)).isoformat(), + (target + timedelta(days=1)).isoformat(), + ) + if df is None or df.empty: + return None + stamp = pd.Timestamp(target) + if stamp not in df.index: + return None # 그날 거래 데이터 없음 — 근사 금지(휴장·상폐 등) + row = df.loc[stamp] + o, c = float(row.get("Open", 0) or 0), float(row.get("Close", 0) or 0) + if o > 0 and c > 0: + return (o + c) / 2 + return c or o or None + except Exception as exc: + logger.debug("과거 시세 조회 실패 {} {}: {}", symbol, day, exc) + return None + + +def find_missing_trading_days( + config: Any, + account_key: str, + since: date, + until: date, + mode: str = "paper", +) -> list[date]: + """since~until 사이 영업일 중 이 계정 스냅샷이 없는 날.""" + from core.trading_hours import TradingHours + from database.models import PortfolioSnapshot, get_session + + session = get_session() + try: + have = { + _as_date(r[0]) + for r in session.query(PortfolioSnapshot.date) + .filter( + PortfolioSnapshot.mode == mode, + PortfolioSnapshot.account_key == account_key, + ) + .all() + } + finally: + session.close() + + th = TradingHours(config) + out: list[date] = [] + d = _as_date(since) + end = _as_date(until) + while d <= end: + if th.is_trading_day(d) and d not in have: + out.append(d) + d += timedelta(days=1) + return out + + +def reconstruct_snapshot( + config: Any, + account_key: str, + day: date, + initial_capital: float, + mode: str = "paper", +) -> dict[str, Any] | None: + """그날의 스냅샷 값을 계산한다. 가격을 못 구하면 None(억지로 채우지 않는다). + + 반환: save_portfolio_snapshot에 넘길 수 있는 dict. + """ + from core.portfolio_manager import twr_period_return + from database.models import CashFlow, PortfolioSnapshot, get_session + + state = replay_ledger(account_key, day, initial_capital, mode=mode) + positions, cash = state["positions"], state["cash"] + + stock_value = 0.0 + for symbol, qty in positions.items(): + mark = historical_mark(symbol, day) + if mark is None: + logger.warning( + "복원 보류 {} {} — {} 시세 없음 (가짜 NAV 방지)", account_key, day, symbol, + ) + return None + stock_value += mark * qty + + total_value = stock_value + cash + if total_value <= 0: + return None + + boundary = datetime.combine(_as_date(day), datetime.max.time()) + session = get_session() + try: + prev = ( + session.query(PortfolioSnapshot) + .filter( + PortfolioSnapshot.mode == mode, + PortfolioSnapshot.account_key == account_key, + PortfolioSnapshot.date < datetime.combine(_as_date(day), datetime.min.time()), + ) + .order_by(PortfolioSnapshot.date.desc()) + .first() + ) + prev_val = float(prev.total_value) if prev else None + prev_cum = float(prev.cumulative_return or 0.0) if prev else None + prev_peak = float(prev.peak_value or 0.0) if prev else 0.0 + prev_date = _as_date(prev.date) if prev else None + flows_before = sum( + float(f.amount or 0) + for f in session.query(CashFlow).filter( + CashFlow.mode == mode, + CashFlow.account_key == account_key, + CashFlow.occurred_at <= boundary, + ).all() + ) + flow_between = 0.0 + if prev_date is not None: + flow_between = sum( + float(f.amount or 0) + for f in session.query(CashFlow).filter( + CashFlow.mode == mode, + CashFlow.account_key == account_key, + CashFlow.occurred_at > datetime.combine(prev_date, datetime.max.time()), + CashFlow.occurred_at <= boundary, + ).all() + ) + finally: + session.close() + + # 누적수익률: 계정에 외부 흐름이 없으면 기존 산식 그대로(하위 호환), + # 있으면 직전 스냅샷에 구간 TWR을 연결한다 — PortfolioManager와 같은 규칙. + if flows_before == 0: + cumulative = ((total_value / initial_capital) - 1) * 100 if initial_capital > 0 else 0.0 + elif prev_val is not None and prev_val > 0: + r = twr_period_return(prev_val, total_value, flow_between) + cumulative = ((1 + (prev_cum or 0.0) / 100) * (1 + r) - 1) * 100 + else: + cumulative = twr_period_return(initial_capital, total_value, flows_before) * 100 + + daily_return = 0.0 + if prev_val is not None and prev_val > 0: + denom = prev_val + flow_between + if denom > 0: + daily_return = (total_value / denom - 1) * 100 + + peak = max(prev_peak, total_value, float(initial_capital)) + mdd = ((peak - total_value) / peak) * 100 if peak > 0 else 0.0 + + return { + "total_value": round(total_value, 0), + "cash": round(cash, 0), + "invested": round(sum(state["cost_basis"].values()), 0), + "daily_return": round(daily_return, 4), + "cumulative_return": round(cumulative, 4), + "mdd": round(mdd, 4), + "peak_value": round(peak, 0), + "position_count": len(positions), + "snapshot_date": datetime.combine(_as_date(day), datetime.min.time()), + } + + +def backfill_account( + config: Any, + account_key: str, + initial_capital: float, + since: date, + until: date, + mode: str = "paper", + dry_run: bool = False, +) -> list[dict[str, Any]]: + """결측 영업일을 찾아 복원한다. 반환: 채운(또는 채울) 날의 요약 목록.""" + from database.repositories import save_portfolio_snapshot + + missing = find_missing_trading_days(config, account_key, since, until, mode=mode) + if not missing: + return [] + + filled: list[dict[str, Any]] = [] + for day in missing: + snap = reconstruct_snapshot(config, account_key, day, initial_capital, mode=mode) + if snap is None: + continue + if not dry_run: + ok = save_portfolio_snapshot( + account_key=account_key, mode=mode, reconstructed=True, **snap, + ) + if not ok: + logger.warning("복원 스냅샷 저장 실패 {} {}", account_key, day) + continue + filled.append({"date": day, **snap}) + logger.info( + "결측 복원{} {} {} — NAV {:,.0f}원 (원장 재생 + 당일 시가·종가 중간값)", + " (DRY RUN)" if dry_run else "", account_key, day, snap["total_value"], + ) + return filled diff --git a/database/models.py b/database/models.py index 30f161ab..40b27c22 100644 --- a/database/models.py +++ b/database/models.py @@ -170,6 +170,11 @@ class PortfolioSnapshot(Base): mdd = Column(Float) # 현재 MDD (%) peak_value = Column(Float) # 역대 최고 평가금 (MDD 기준점, 재시작 시 복구용) position_count = Column(Integer, default=0) # 보유 종목 수 + # 이 행이 사이클 실행으로 실측된 것이 아니라, 사후에 원장 재생 + 과거 시세로 + # 재구성된 것인지. 결측일을 메우되 '실측'과 섞이지 않게 구분한다 — 커버리지 95% + # 게이트는 '시스템이 실제로 돌았는가'를 보는 장치라, 보정분이 실측인 척하면 + # 게이트가 목적을 잃는다. 평가 출력이 이 값으로 실측/보정을 나눠 표기한다. + reconstructed = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.now) __table_args__ = ( @@ -520,6 +525,36 @@ def _migrate_trade_history_slippage_columns(engine): raise +def _migrate_snapshot_reconstructed_column(engine): + """기존 DB에 portfolio_snapshots.reconstructed 컬럼 추가. + + 결측일 보정 스냅샷을 실측과 구분하기 위한 표시. 없으면 보정분이 실측처럼 보여 + 커버리지 게이트가 '시스템이 돌았는가'를 더 이상 검증하지 못한다. + """ + from sqlalchemy import text + dialect = engine.url.get_dialect().name + col_type = "BOOLEAN" if dialect == "sqlite" else "BOOLEAN" + with engine.connect() as conn: + try: + if dialect == "sqlite": + r = conn.execute(text("PRAGMA table_info(portfolio_snapshots)")) + if any(row[1] == "reconstructed" for row in r.fetchall()): + return + conn.execute(text( + f"ALTER TABLE portfolio_snapshots ADD COLUMN reconstructed {col_type} DEFAULT 0" + )) + else: + conn.execute(text( + "ALTER TABLE portfolio_snapshots ADD COLUMN IF NOT EXISTS " + f"reconstructed {col_type} DEFAULT FALSE" + )) + conn.commit() + except Exception as e: + conn.rollback() + if not ("duplicate column" in str(e).lower() or "already exists" in str(e).lower()): + raise + + def _migrate_trade_history_signal_columns(engine): """기존 DB에 signal_at, order_at, price_gap 컬럼 추가 (감사 항목 4 대응).""" from sqlalchemy import text @@ -1109,6 +1144,10 @@ def init_database(): _migrate_trade_history_slippage_columns(engine) except Exception: pass + try: + _migrate_snapshot_reconstructed_column(engine) + except Exception: + pass try: _migrate_trade_history_signal_columns(engine) except Exception: diff --git a/database/repositories.py b/database/repositories.py index 3f59e781..129cf992 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -784,6 +784,7 @@ def save_portfolio_snapshot( peak_value: float = None, snapshot_date: datetime = None, mode: str = "paper", + reconstructed: bool = False, ): """일일 포트폴리오 스냅샷 저장 (mode+account_key 장부별 격리). @@ -810,6 +811,7 @@ def save_portfolio_snapshot( mdd=mdd, peak_value=peak_value, position_count=position_count, + reconstructed=bool(reconstructed), ) # merge by (mode, account_key, date) existing = session.query(PortfolioSnapshot).filter( @@ -826,6 +828,10 @@ def save_portfolio_snapshot( existing.mdd = mdd existing.peak_value = peak_value existing.position_count = position_count + # 실측이 나중에 들어오면 보정 표시를 걷어낸다(실측이 항상 우선). + # 반대로 복원이 실측 행을 보정으로 덮어쓰지는 않는다. + if not reconstructed: + existing.reconstructed = False # created_at은 '이 값이 마지막으로 측정된 시각'이다 — TWR 체인의 유입 경계가 # 이 시각을 쓰므로, 같은 날 재실행(upsert) 때 갱신하지 않으면 재실행 전에 # 반영된 입금이 다음 날 구간에 이중 산입돼 수익률이 영구 왜곡된다(적대적 리뷰 HIGH). diff --git a/main.py b/main.py index b3a64b28..c7afdf07 100644 --- a/main.py +++ b/main.py @@ -34,6 +34,11 @@ import os import sys import argparse + +# 결측 스냅샷 자동 보충의 소급 한도(달력일). 짧게 잡는다 — 며칠짜리 사고는 메우되 +# 장기 중단은 공백으로 남겨 드러나야 한다. 보충이 무제한이면 '시스템이 실제로 +# 돌았는가'를 보는 커버리지 게이트가 목적을 잃는다. +BACKFILL_LOOKBACK_DAYS = 10 from pathlib import Path # 프로젝트 루트를 경로에 추가 @@ -677,6 +682,7 @@ def run_rebalance(args): def _run_rebalance_impl(args): """바스켓 포트폴리오 리밸런싱 모드.""" from datetime import datetime + from datetime import timedelta from zoneinfo import ZoneInfo from core.basket_rebalancer import BasketRebalancer from core.notifier import Notifier @@ -801,6 +807,44 @@ def _run_rebalance_impl(args): except Exception as guard_exc: logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc) + # 최근 결측 스냅샷 자동 보충 — 매매보다 먼저. 매매를 먼저 하면 원장이 + # 바뀌어 과거 날짜 재구성이 헷갈릴 여지가 생긴다(재생 자체는 시각 기준이라 + # 정확하지만, 순서를 고정해 두는 편이 읽기 쉽다). + # + # 왜 필요한가: _nav_attribution_date가 '오늘이 거래일이면 오늘'로 귀속하므로 + # 어제 사이클이 안 돌면 그 하루는 영원히 빈다. 실제로 2026-08-18 결측이 + # 8/19~8/26 내내 그대로 남아 커버리지를 승격 기준(95%) 아래로 끌어내렸다. + # 복원분은 reconstructed=True로 표시돼 평가가 실측과 나눠 표기한다. + if not dry_run: + try: + from core.snapshot_backfill import backfill_account + + _cap = (baskets_cfg.get(name) or {}).get("initial_capital") + _today = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None).date() + filled = backfill_account( + config, + live_strategy_name, + float(_cap) if _cap is not None else float( + (config.risk_params.get("position_sizing") or {}) + .get("initial_capital", 10_000_000) + ), + _today - timedelta(days=BACKFILL_LOOKBACK_DAYS), + _today - timedelta(days=1), # 오늘은 아래 스냅샷 단계가 찍는다 + mode=mode, + ) + if filled: + msg = ( + f"바스켓 '{name}' 결측 스냅샷 {len(filled)}일 복원: " + + ", ".join(str(f["date"]) for f in filled[:5]) + ) + logger.warning(msg) + record_cycle_event( + "SNAPSHOT_BACKFILLED", msg, severity="warning", + strategy=live_strategy_name, mode=mode, + ) + except Exception as bf_exc: + logger.warning("바스켓 '{}' 결측 보충 생략: {}", name, bf_exc) + # 리스크 청산(손절/익절/트레일링)을 리밸런싱보다 먼저 평가한다. # 그동안 이 사이클은 비중 교정만 했고 손절/익절은 장중 스케줄러 # (core/scheduler.py)에만 있어서, 일 1회 리밸런싱으로만 굴러가는 바스켓 diff --git a/tests/test_paper_pilot.py b/tests/test_paper_pilot.py index 4b98f050..e70fc70d 100644 --- a/tests/test_paper_pilot.py +++ b/tests/test_paper_pilot.py @@ -855,9 +855,16 @@ def test_approved_strategies_not_changed(self, evidence_dir, runtime_dir, fresh_ class TestPilotEvidenceFreshness: def test_business_day_freshness_ignores_weekends_and_holidays(self, evidence_dir, runtime_dir, fresh_db): - """긴 주말/휴장일은 stale 영업일 수에 포함하지 않는다.""" + """긴 주말/휴장일은 stale 영업일 수에 포함하지 않는다. + + 2026-02-13(금) 이후 16~18일이 설 연휴라 다음 거래일은 19일이다. 달력이 이 + 연휴를 모르면 stale 일수가 3일이 아니라 6일로 잡혀 pilot이 막힌다. + (종전에는 1/26→2/3 구간을 썼는데, 1/27~29를 연휴로 가정한 것이었다. 그 날들은 + 실제로 개장했고 — 2025년 설 날짜가 달력에 잘못 들어가 있었다 — 2026-08-26에 + 시장 데이터와 대조해 바로잡았다.) + """ _seed_v2(evidence_dir, PILOT_STRATEGY, [ - {"date": "2026-01-26", "benchmark_status": "final"}, + {"date": "2026-02-13", "benchmark_status": "final"}, ]) from core.paper_pilot import enable_pilot, check_pilot_entry, compute_launch_readiness @@ -866,8 +873,8 @@ def test_business_day_freshness_ignores_weekends_and_holidays(self, evidence_dir enable_pilot(PILOT_STRATEGY, "2026-01-01", "2026-02-28") - result = check_pilot_entry(PILOT_STRATEGY, as_of_date="2026-02-03") - readiness = compute_launch_readiness(PILOT_STRATEGY, as_of_date="2026-02-03") + result = check_pilot_entry(PILOT_STRATEGY, as_of_date="2026-02-23") + readiness = compute_launch_readiness(PILOT_STRATEGY, as_of_date="2026-02-23") assert result.allowed is True assert readiness["evidence_fresh"] is True diff --git a/tests/test_snapshot_backfill.py b/tests/test_snapshot_backfill.py new file mode 100644 index 00000000..ecf3ec57 --- /dev/null +++ b/tests/test_snapshot_backfill.py @@ -0,0 +1,235 @@ +"""결측 스냅샷 복원 + 거래일 달력 정합 테스트. + +배경(2026-08-26): 승격 기준인 스냅샷 커버리지 95%가 두 트랙 다 미달로 나왔다. +파고 보니 원인이 둘이었고, 큰 쪽은 결측이 아니었다. + + 1) 거래일 달력 오류 — config/holidays.yaml에 2026-07-17이 빠져 있어 휴장일이 + 영업일로 집계됐다. 분모만 부풀어 kr_diversified_hold 96.3%가 94.5%로, + kr_pocket 96.9%가 93.9%로 보였다. 전 구간을 시장 데이터와 대조하니 7건이 + 더 나왔다(설 연휴가 2025년 날짜로 들어가 있었다). + 2) 진짜 결측 — 6/26, 8/18. 사이클이 안 돈 날이다. `_nav_attribution_date`가 + '오늘이 거래일이면 오늘'로 귀속해 다음날 실행이 어제를 못 메운다. + +복원은 원장 재생으로 포지션·현금을 정확히 되살리고 NAV만 당일 OHLC로 근사한다. +근사분은 reconstructed=True로 표시해 평가가 실측과 나눠 표기한다 — 커버리지 게이트는 +'시스템이 실제로 돌았는가'를 보는 장치라, 보정분이 실측인 척하면 목적을 잃는다. +""" + +from datetime import date, datetime, timedelta + +import pytest + +from core.basket_evaluation import evaluate_basket_paper_operation +from core.snapshot_backfill import replay_ledger +from database.models import init_database +from database.models import TradeHistory, get_session +from database.repositories import record_cash_flow, save_portfolio_snapshot + + +def _trade(key, symbol, action, price, qty, when, commission=0, tax=0): + """executed_at을 명시해 과거 거래를 심는다. + + save_trade는 executed_at을 컬럼 기본값(now)으로 채우므로 과거 시점 원장을 + 만들 수 없다 — 재생 로직은 그 시각으로 자르기 때문에 직접 넣어야 한다. + """ + session = get_session() + try: + session.add(TradeHistory( + account_key=key, symbol=symbol, action=action, price=price, + quantity=qty, total_amount=price * qty, commission=commission, + tax=tax, slippage=0, strategy=key, mode="paper", + executed_at=when, order_at=when, + )) + session.commit() + finally: + session.close() + + +# ------------------------------------------------------------- 원장 재생 + +class TestReplayLedger: + """포지션·현금은 근사가 아니라 정확히 복원돼야 한다.""" + + def test_buys_then_partial_sell(self): + init_database() + key = "basket_rebalance:replay_a" + _trade(key, "005930", "BUY", 1000, 10, datetime(2026, 6, 10, 10, 0), commission=15) + _trade(key, "005930", "SELL", 1200, 4, datetime(2026, 6, 12, 10, 0), + commission=18, tax=24) + + state = replay_ledger(key, date(2026, 6, 30), 100_000) + assert state["positions"] == {"005930": 6} + # 현금 = 10만 - (1만 + 15) + (4,800 - 42) + assert state["cash"] == pytest.approx(100_000 - 10_015 + 4_758) + # 원가는 매도분만큼 비례 차감 (10주 1만원 → 6주 6천원) + assert state["cost_basis"]["005930"] == pytest.approx(6_000) + + def test_asof_date_excludes_later_trades(self): + init_database() + key = "basket_rebalance:replay_b" + _trade(key, "005930", "BUY", 1000, 5, datetime(2026, 6, 10, 10, 0)) + _trade(key, "000660", "BUY", 2000, 3, datetime(2026, 7, 20, 10, 0)) + + early = replay_ledger(key, date(2026, 6, 30), 100_000) + assert early["positions"] == {"005930": 5}, "이후 거래가 과거 상태에 섞였다" + + late = replay_ledger(key, date(2026, 7, 31), 100_000) + assert late["positions"] == {"005930": 5, "000660": 3} + + def test_deposits_are_included_in_cash(self): + init_database() + key = "basket_rebalance:replay_c" + record_cash_flow(amount=50_000, account_key=key, mode="paper", note="적립") + state = replay_ledger(key, date.today(), 100_000) + assert state["cash"] == pytest.approx(150_000) + + def test_fully_closed_position_disappears(self): + init_database() + key = "basket_rebalance:replay_d" + _trade(key, "005930", "BUY", 1000, 5, datetime(2026, 6, 10, 10, 0)) + _trade(key, "005930", "SELL", 1100, 5, datetime(2026, 6, 11, 10, 0)) + assert replay_ledger(key, date(2026, 6, 30), 100_000)["positions"] == {} + + +# ------------------------------------------- 복원 표시가 평가에 드러나는가 + +def _snap(key, d, value, reconstructed=False): + save_portfolio_snapshot( + total_value=value, cash=value, invested=0, + cumulative_return=0.0, mdd=0.0, position_count=0, + account_key=key, mode="paper", + snapshot_date=datetime(d.year, d.month, d.day), + reconstructed=reconstructed, + ) + + +class TestReconstructedDisclosure: + """보정분이 실측과 섞여 보이면 게이트가 목적을 잃는다.""" + + def test_measured_and_reconstructed_are_split(self): + r = evaluate_basket_paper_operation( + operation_start=date(2026, 6, 1), today=date(2026, 6, 30), + trading_days_total=20, snapshot_days=20, reconstructed_days=3, + pending_failed_orders=0, total_costs=0, initial_capital=1_000_000, + ) + assert r["snapshot_coverage"] == pytest.approx(1.0) + assert r["reconstructed_days"] == 3 + assert r["measured_days"] == 17 + assert r["measured_coverage"] == pytest.approx(0.85) + + def test_no_reconstruction_keeps_measured_equal_to_total(self): + r = evaluate_basket_paper_operation( + operation_start=date(2026, 6, 1), today=date(2026, 6, 30), + trading_days_total=20, snapshot_days=19, + pending_failed_orders=0, total_costs=0, initial_capital=1_000_000, + ) + assert r["reconstructed_days"] == 0 + assert r["measured_days"] == 19 + assert r["measured_coverage"] == pytest.approx(r["snapshot_coverage"]) + + def test_reconstructed_cannot_exceed_snapshot_days(self): + r = evaluate_basket_paper_operation( + operation_start=date(2026, 6, 1), today=date(2026, 6, 30), + trading_days_total=10, snapshot_days=5, reconstructed_days=99, + pending_failed_orders=0, total_costs=0, initial_capital=1_000_000, + ) + assert r["reconstructed_days"] == 5 + assert r["measured_days"] == 0 + + def test_report_text_shows_the_split(self): + from core.basket_evaluation import format_evaluation_report + + r = evaluate_basket_paper_operation( + operation_start=date(2026, 6, 1), today=date(2026, 6, 30), + trading_days_total=20, snapshot_days=20, reconstructed_days=2, + pending_failed_orders=0, total_costs=0, initial_capital=1_000_000, + ) + text = format_evaluation_report(r, "t") + assert "사후 복원" in text and "실측" in text + + def test_measured_row_wins_over_reconstructed_on_upsert(self): + """나중에 실측이 들어오면 보정 표시를 걷어낸다.""" + from database.models import PortfolioSnapshot, get_session + + init_database() + key = "basket_rebalance:upsert_mark" + d = date(2026, 6, 15) + _snap(key, d, 1_000, reconstructed=True) + _snap(key, d, 1_100, reconstructed=False) + + session = get_session() + try: + row = session.query(PortfolioSnapshot).filter( + PortfolioSnapshot.account_key == key, + PortfolioSnapshot.mode == "paper", + ).first() + assert row.total_value == pytest.approx(1_100) + assert not row.reconstructed, "실측이 보정 표시를 걷어내지 못했다" + finally: + session.close() + + +# ----------------------------------------------------- 거래일 달력 정합 + +class TestTradingCalendar: + """달력 오류는 양방향으로 조용히 해롭다 — 분모를 부풀리거나 진짜 결측을 가린다.""" + + def test_known_market_closures_are_registered(self): + """시장 데이터로 확인된 휴장일이 달력에 있어야 한다. + + 빠지면 그 날이 영업일로 집계돼 커버리지 분모가 부풀고, 없는 결측을 쫓게 된다. + 2026-07-17 하나가 두 트랙을 95% 아래로 끌어내렸다. + """ + from config.config_loader import Config + from core.trading_hours import TradingHours + + th = TradingHours(Config.get()) + # KOSPI 지수·개별 종목 모두 봉이 없어 휴장이 확인된 날들 + for closed in ( + date(2026, 2, 16), date(2026, 2, 17), date(2026, 2, 18), # 설 연휴 + date(2026, 5, 1), # 근로자의 날 + date(2026, 6, 3), # 지방선거 + date(2026, 7, 17), + ): + assert not th.is_trading_day( + datetime(closed.year, closed.month, closed.day) + ), f"{closed}는 휴장일인데 거래일로 판정된다" + + def test_open_days_are_not_marked_holiday(self): + """실제 개장한 날이 휴장으로 등록돼 있으면 진짜 결측이 집계에서 사라진다.""" + from config.config_loader import Config + from core.trading_hours import TradingHours + + th = TradingHours(Config.get()) + for open_day in (date(2026, 1, 27), date(2026, 1, 28), date(2026, 1, 29)): + assert th.is_trading_day( + datetime(open_day.year, open_day.month, open_day.day) + ), f"{open_day}는 개장일인데 휴장으로 등록돼 있다" + + def test_verifier_reports_mismatch(self, monkeypatch): + """검증 도구가 불일치를 실제로 잡아내는가(달력이 맞아도 로직은 살아 있어야).""" + import tools.verify_trading_calendar as v + + # 시장은 6/1만 열렸다고 가정 — 나머지 평일은 전부 wrong_open이어야 한다 + monkeypatch.setattr(v, "market_open_days", lambda s, e, symbol=None: {date(2026, 6, 1)}) + r = v.compare(date(2026, 6, 1), date(2026, 6, 5)) + assert date(2026, 6, 1) not in r["wrong_open"] + assert date(2026, 6, 2) in r["wrong_open"] + + def test_verifier_does_not_report_when_data_unavailable(self, monkeypatch): + """데이터 소스 장애를 달력 오류로 둔갑시키지 않는다.""" + import tools.verify_trading_calendar as v + + monkeypatch.setattr(v, "market_open_days", lambda s, e, symbol=None: None) + r = v.compare(date(2026, 6, 1), date(2026, 6, 5)) + assert r.get("error") + assert r["wrong_open"] == [] and r["wrong_closed"] == [] + + +# --------------------------------------------------------- 보충 소급 한도 + +def test_backfill_lookback_is_bounded(): + """무제한 소급이면 장기 중단도 메워져 커버리지 게이트가 목적을 잃는다.""" + import main + + assert 0 < main.BACKFILL_LOOKBACK_DAYS <= 30 diff --git a/tests/test_target_weight_paper_adapter.py b/tests/test_target_weight_paper_adapter.py index d7d76911..601d6563 100644 --- a/tests/test_target_weight_paper_adapter.py +++ b/tests/test_target_weight_paper_adapter.py @@ -8444,12 +8444,14 @@ def test_recommend_pilot_caps_matches_target_weight_plan(monkeypatch): assert rec["suggested_preview"]["allowed"] is True assert rec["valid_from"] == "2026-04-10" # 2026-05-25(부처님오신날 대체휴일) 반영으로 60영업일 만기가 1영업일 밀림 - assert rec["valid_to"] == "2026-07-06" + # 2026-08-26 달력 교정으로 05-01(근로자의날)·06-03(지방선거)이 휴장일로 추가돼 + # 영업일 창이 이틀 뒤로 밀렸다(07-06 → 07-08). 로직 변경이 아니라 달력 정정 결과다. + assert rec["valid_to"] == "2026-07-08" assert rec["target_pilot_days"] == 60 assert "YYYY-MM-DD" not in rec["enable_command"] assert "\n" not in rec["enable_command"] assert "\\" not in rec["enable_command"] - assert "--from 2026-04-10 --to 2026-07-06" in rec["enable_command"] + assert "--from 2026-04-10 --to 2026-07-08" in rec["enable_command"] assert "--max-orders 3 --max-positions 3" in rec["enable_command"] assert "--max-notional 1260000 --max-exposure 3360000" in rec["enable_command"] diff --git a/tools/verify_trading_calendar.py b/tools/verify_trading_calendar.py new file mode 100644 index 00000000..276616a7 --- /dev/null +++ b/tools/verify_trading_calendar.py @@ -0,0 +1,119 @@ +"""거래일 달력을 실제 시장 데이터와 대조한다. + +왜 필요한가(2026-08-26 실측): +config/holidays.yaml에 2026-07-17이 빠져 있어 그날이 거래일로 판정됐다. 그만큼 스냅샷 +커버리지 분모가 부풀려져 두 트랙이 실제보다 낮게 나왔다 — kr_diversified_hold 96.3%가 +94.5%로, kr_pocket 96.9%가 93.9%로. 승격 기준이 95%라 이 한 줄이 판정을 뒤집는다. + +달력 오류는 양방향으로 조용히 해롭다: + 휴장일을 거래일로 오인 → 분모 과대 → 커버리지 저평가, 없는 결측을 쫓게 된다 + 거래일을 휴장일로 오인 → 진짜 결측이 집계에서 사라진다(더 위험) + +자동 갱신(pykrx)이 이 환경에서 깨져 있어 조용히 놓친 것이므로, 달력을 신뢰하지 말고 +시장이 실제로 열렸는지를 지수 데이터로 직접 확인한다. + +사용: + .venv\\Scripts\\python.exe tools/verify_trading_calendar.py + .venv\\Scripts\\python.exe tools/verify_trading_calendar.py --start 2026-01-01 --end 2026-12-31 + +종료 코드: 0 = 일치, 1 = 불일치 발견(수정 필요). +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import date, datetime, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +BENCHMARK = "KS11" +_WEEKDAY = ["월", "화", "수", "목", "금", "토", "일"] + + +def market_open_days(start: date, end: date, symbol: str = BENCHMARK) -> set[date] | None: + """지수 데이터가 존재하는 날 = 시장이 열린 날. 조회 실패 시 None.""" + try: + import FinanceDataReader as fdr + + df = fdr.DataReader(symbol, start.isoformat(), end.isoformat()) + if df is None or df.empty: + return None + return {d.date() for d in df.index} + except Exception as exc: + print(f"시장 데이터 조회 실패({symbol}): {exc}") + return None + + +def compare(start: date, end: date) -> dict: + """달력 판정 vs 실제 개장 여부를 대조한다. + + 반환: {"wrong_open": [...], "wrong_closed": [...], "checked": int} + wrong_open 달력은 거래일이라는데 시장은 닫혀 있던 날 (분모 과대) + wrong_closed 달력은 휴장이라는데 시장은 열려 있던 날 (결측 은폐) + """ + from config.config_loader import Config + from core.trading_hours import TradingHours + + opened = market_open_days(start, end) + if opened is None: + return {"error": "시장 데이터를 가져오지 못해 대조 불가", "wrong_open": [], "wrong_closed": []} + + th = TradingHours(Config.get()) + wrong_open: list[date] = [] + wrong_closed: list[date] = [] + checked = 0 + d = start + while d <= end: + # 주말은 양쪽 다 휴장이 자명하므로 건너뛴다(데이터 없음 = 정상). + if d.weekday() < 5: + checked += 1 + says_trading = th.is_trading_day(d) + actually_open = d in opened + if says_trading and not actually_open: + wrong_open.append(d) + elif not says_trading and actually_open: + wrong_closed.append(d) + d += timedelta(days=1) + + return {"wrong_open": wrong_open, "wrong_closed": wrong_closed, "checked": checked} + + +def main() -> int: + ap = argparse.ArgumentParser(description="거래일 달력 vs 실제 시장 데이터 대조") + ap.add_argument("--start", default=None, help="시작일 (기본: 180일 전)") + ap.add_argument("--end", default=None, help="종료일 (기본: 오늘)") + args = ap.parse_args() + + end = date.fromisoformat(args.end) if args.end else date.today() + start = date.fromisoformat(args.start) if args.start else end - timedelta(days=180) + + print(f"거래일 달력 대조: {start} ~ {end} (기준 {BENCHMARK})") + result = compare(start, end) + if result.get("error"): + print(f" ⚠️ {result['error']}") + return 0 # 데이터 소스 장애를 달력 오류로 보고하지 않는다 + + wo, wc = result["wrong_open"], result["wrong_closed"] + print(f" 평일 {result['checked']}일 확인") + + if wo: + print(f"\n ❌ 거래일로 판정했지만 실제 휴장 ({len(wo)}일) — 커버리지 분모 과대") + for d in wo: + print(f" {d} ({_WEEKDAY[d.weekday()]})") + print(" → config/holidays.yaml 에 추가하세요.") + if wc: + print(f"\n ❌ 휴장으로 판정했지만 실제 개장 ({len(wc)}일) — 진짜 결측이 가려짐") + for d in wc: + print(f" {d} ({_WEEKDAY[d.weekday()]})") + print(" → config/holidays.yaml 에서 제거하세요.") + + if not wo and not wc: + print(" ✅ 달력과 시장 데이터 일치") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())