${escapeHtml(name)}${basket?.is_primary ? " · 주력" : ""}
-
${item.paper_only ? `전체 운영 ${days}거래일` : `${days} / ${minimum} 거래일`}${coverage == null ? "기록 누락 확인 중" : coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`}
+
${item.paper_only ? `전체 운영 ${days}거래일` : `${days} / ${minimum} 거래일`}${coverageText}
${copy}
${issues.length ? `
${issues.map((i) => `- ${escapeHtml(i)}
`).join("")}
` : ""}
@@ -1700,6 +1738,7 @@
if (v === "bearish")
return ["하락 추세", "시장 추세 기준 매수 제한", "warning"];
if (v === "caution") return ["주의", "포지션 축소 구간", "warning"];
+ if (v === "disabled") return ["사용 안 함", "시장 추세 판단을 꺼 두었습니다", "info"];
return ["확인 불가", "시장 상태 데이터 없음", "warning"];
}
const strategyCopy = (s) =>
@@ -1791,16 +1830,22 @@
const loopAge = loopLast
? Math.max(0, Math.round((Date.now() - loopLast.getTime()) / 60_000))
: null;
- const loopFresh = loopAge != null && loopAge <= 720;
- const autoValue = loopElapsed
- ? loopFresh
- ? "정상"
- : `${formatAge(loopAge)} 실행`
- : "기록 없음";
- const autoDetail = loopElapsed
- ? `마지막 실행 ${fmtDT(loopLast)} · 루프 ${loopElapsed}`
- : "스케줄러 기록 없음";
- const autoState = loop && loopFresh ? "ok" : "warning";
+ // 멈춤 판정은 서버가 장 운영 시간 기준으로 한다(밤·주말·휴장일은 멈춘 게 아니다).
+ const schedulerUnused = runtime.scheduler_in_use === false;
+ const loopFresh = !runtime.scheduler_stale && loopAge != null;
+ const autoValue = schedulerUnused
+ ? "사용 안 함"
+ : loopElapsed
+ ? loopFresh
+ ? "정상"
+ : `${formatAge(loopAge)} 실행`
+ : "기록 없음";
+ const autoDetail = schedulerUnused
+ ? "매매는 평일 오전 10시쯤 한 번 실행됩니다"
+ : loopElapsed
+ ? `마지막 실행 ${fmtDT(loopLast)} · 루프 ${loopElapsed}`
+ : "스케줄러 기록 없음";
+ const autoState = schedulerUnused ? "info" : loop && loopFresh ? "ok" : "warning";
const kisValue =
kis && kis.minute_utilization_pct != null
? `${Number(kis.minute_utilization_pct).toFixed(1)}% 사용`
@@ -1939,6 +1984,14 @@
function renderLegacy(portfolio) {
state.legacy = portfolio || null;
+ const legacyEmpty = Boolean(portfolio && portfolio.empty);
+ $("legacyHeading").hidden = legacyEmpty;
+ $("summary").hidden = legacyEmpty;
+ if (legacyEmpty) {
+ $("positionsWrap").hidden = true;
+ $("noPositions").hidden = true;
+ return;
+ }
if (!portfolio) {
$("summary").innerHTML = statusRowItem(
"이전 계좌",
@@ -2199,9 +2252,13 @@
})
.then((data) => renderEvaluations((data && data.evaluations) || []))
.catch(() => {
+ // 이전에 성공한 결과로 계속 판단하지 않는다(그 사이 생긴 문제를 못 본다)
+ state.evaluations = null;
+ state.evaluationsStatus = "error";
el.basketEval.setAttribute("aria-busy", "false");
el.basketEval.innerHTML =
'
검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.
';
+ renderDecision();
});
const runtimeTask = fetchJson("/api/runtime", {
timeout: 30_000,
diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html
index 7c7f1baf..2512867e 100644
--- a/monitoring/templates/dashboard.html
+++ b/monitoring/templates/dashboard.html
@@ -462,7 +462,7 @@
거래 중지 상태에서 먼저 할 일
웹소켓 상태를 아직 받지 못했습니다.
-
이전 기본 계좌
+
이전 기본 계좌
dict:
- """레거시 기본 계정의 현재 포트폴리오 요약을 반환한다."""
- global _DASH
+ """이전 기본 계정('' 계정)의 요약 — DB만 읽는다.
+
+ 예전에는 계정 키 ''를 '전 계정 합산'으로 해석하는 경로를 거쳐, 바스켓 두 계정의
+ 체결을 섞은 가짜 계좌(-4.44%, ETF 행 중복)를 '이전 기본 계좌'로 보여 줬다. live에서는
+ 조회할 때마다 증권사 토큰을 새로 받을 수 있었다. 이 화면은 증권사에 연결하지 않는다.
+ 기본 계정에 아무 기록이 없으면 {"empty": True}를 돌려주고 화면은 패널을 숨긴다.
+ """
+ from database.repositories import (
+ get_all_positions,
+ get_cash_flow_total,
+ get_trade_cash_summary,
+ get_trade_history,
+ )
+
config = Config.get()
- if _DASH is None:
- _DASH = Dashboard(config=config)
- dash = _DASH
- summary = dash.portfolio_manager.get_portfolio_summary(current_prices or {})
+ ledger_mode = _active_ledger_mode(config)
+ base = {"timestamp": datetime.now().isoformat(), "mode": ledger_mode}
+ positions = [
+ p for p in (get_all_positions(account_key="", mode=ledger_mode) or [])
+ if (p.quantity or 0) > 0
+ ]
+ trades = get_trade_history(mode=ledger_mode, account_key="") or []
+ if not positions and not trades:
+ return {**base, "empty": True}
+
+ initial = float(
+ (config.risk_params.get("position_sizing") or {}).get("initial_capital", 10_000_000)
+ )
+ prices = current_prices or {}
+ deposits = float(get_cash_flow_total(account_key="", mode=ledger_mode) or 0)
+ cash_delta = float(
+ (get_trade_cash_summary(mode=ledger_mode, account_key="") or {}).get("cash_delta") or 0
+ )
+ cash = initial + deposits + cash_delta
+ details = []
+ invested = current_value = 0.0
+ for p in positions:
+ qty = int(p.quantity or 0)
+ avg = float(p.avg_price or 0)
+ price = float(prices.get(p.symbol, avg) or avg)
+ invested += avg * qty
+ current_value += price * qty
+ details.append({
+ "symbol": p.symbol, "quantity": qty, "avg_price": avg,
+ "current_price": price, "invested": avg * qty,
+ "current_value": price * qty, "pnl": (price - avg) * qty,
+ "pnl_rate": ((price / avg) - 1) * 100 if avg > 0 else 0.0,
+ })
+ total_value = cash + current_value
+ principal = initial + deposits
return {
- "timestamp": datetime.now().isoformat(),
- "mode": _active_ledger_mode(config),
- "initial_capital": dash.initial_capital,
- "total_value": summary["total_value"],
- "cash": summary["cash"],
- "invested": summary["invested"],
- "current_value": summary["current_value"],
- "total_return": summary["total_return"],
- "mdd": summary["mdd"],
- "position_count": summary["position_count"],
- "realized_pnl": summary["realized_pnl"],
- "unrealized_pnl": summary["unrealized_pnl"],
- "positions": summary["positions"],
+ **base,
+ "empty": False,
+ "initial_capital": initial,
+ "total_value": total_value,
+ "cash": cash,
+ "invested": invested,
+ "current_value": current_value,
+ "total_return": ((total_value / principal) - 1) * 100 if principal > 0 else 0.0,
+ "mdd": None,
+ "position_count": len(details),
+ "realized_pnl": cash + invested - principal,
+ "unrealized_pnl": current_value - invested,
+ "positions": details,
}
+_CALENDAR = None
+
+
+def _calendar():
+ """KRX 거래일 달력(프로세스당 한 번 로드)."""
+ global _CALENDAR
+ if _CALENDAR is None:
+ from core.trading_hours import TradingHours
+
+ _CALENDAR = TradingHours()
+ return _CALENDAR
+
+
+def _snapshot_freshness(last_date, now: Optional[datetime] = None) -> dict:
+ """일일 사이클이 남겼어야 할 마지막 기록일과, 그 뒤로 빠진 거래일 수(KRX 달력 기준).
+
+ 예전 화면 규칙은 달력 날짜(4일)와 스케줄러 루프 나이(720분)였다. 이 배포에서는
+ 스케줄러가 돌지 않아 루프 경보가 밤·주말·휴장일마다 울렸고, 반대로 이틀 연속
+ 사이클이 죽어도 '정상'이었다. 사이클은 평일 10시대에 돌므로 10:30 전의 오늘은
+ 아직 기대하지 않는다.
+ """
+ from datetime import time as _time, timedelta as _td
+ from core.trading_hours import _now_kst
+
+ now = now or _now_kst()
+ th = _calendar()
+ day = now.date()
+ if not (th.is_trading_day(datetime(day.year, day.month, day.day)) and now.time() >= _time(10, 30)):
+ day -= _td(days=1)
+ for _ in range(20):
+ if th.is_trading_day(datetime(day.year, day.month, day.day)):
+ break
+ day -= _td(days=1)
+ expected = day
+ if last_date is None:
+ return {"expected_snapshot_date": expected.isoformat(), "missed_trading_days": None}
+ last = last_date.date() if hasattr(last_date, "date") else last_date
+ missed = 0
+ d = last + _td(days=1)
+ while d <= expected and missed < 60:
+ if th.is_trading_day(datetime(d.year, d.month, d.day)):
+ missed += 1
+ d += _td(days=1)
+ return {"expected_snapshot_date": expected.isoformat(), "missed_trading_days": missed}
+
+
def get_snapshots_json(days: int = 30, account_key: Optional[str] = None) -> dict:
"""최근 N일 스냅샷을 활성 장부 모드에서 반환한다."""
config = Config.get()
@@ -154,7 +239,13 @@ def get_baskets_json() -> dict:
)
snapshot = None
deployment_ratio = None
+ latest_measured = None
if latest is not None:
+ # 스냅샷을 실제로 찍은 시각. 이 시각 뒤의 입금은 아직 평가액에 없다.
+ latest_measured = latest.created_at or datetime.combine(
+ latest.date.date() if hasattr(latest.date, "date") else latest.date,
+ datetime.max.time(),
+ )
total_value = float(latest.total_value or 0)
cash = float(latest.cash or 0)
deployment_ratio = (
@@ -172,6 +263,25 @@ def get_baskets_json() -> dict:
finally:
session.close()
+ # 원금 대비 손익은 스냅샷 시점의 원금으로 잰다. 전체 원금(방금 넣은 적립 포함)과
+ # 비교하면 적립 직후 다음 사이클까지 '원금 대비 -21%' 같은 가짜 손실이 보인다
+ # (연휴 앞 적립이면 며칠씩). 아직 반영 안 된 적립은 따로 알려 준다.
+ principal_at_snapshot = principal
+ pending_deposits = 0.0
+ if latest_measured is not None:
+ deposits_at_snapshot = float(
+ get_cash_flow_total(
+ account_key=account_key, until=latest_measured, mode=ledger_mode,
+ ) or 0
+ )
+ principal_at_snapshot = initial_capital + deposits_at_snapshot
+ pending_deposits = deposits_total - deposits_at_snapshot
+ try:
+ freshness = _snapshot_freshness(latest.date if latest is not None else None)
+ except Exception as exc:
+ logger.warning("바스켓 '{}' 기록 공백 계산 실패: {}", name, exc)
+ freshness = {"expected_snapshot_date": None, "missed_trading_days": None}
+
holding_names = basket_config.get("holding_names") or {}
positions = [
{
@@ -244,10 +354,14 @@ def get_baskets_json() -> dict:
"initial_capital": initial_capital,
"deposits_total": deposits_total,
"principal": principal,
+ "principal_at_snapshot": principal_at_snapshot,
+ "pending_deposits": pending_deposits,
"snapshot": snapshot,
"profit_vs_principal": (
- snapshot["total_value"] - principal if snapshot else None
+ snapshot["total_value"] - principal_at_snapshot if snapshot else None
),
+ "expected_snapshot_date": freshness["expected_snapshot_date"],
+ "missed_trading_days": freshness["missed_trading_days"],
"deployment_ratio": deployment_ratio,
"design_fraction": design_fraction,
"base_stock_fraction": base_fraction,
@@ -304,13 +418,20 @@ def get_runtime_json() -> dict:
from core.data_collector import DataCollector
from core.market_regime import check_market_regime
+ from core.market_regime import resolve_market_regime_config
+
config = Config.get()
- regime = check_market_regime(config, DataCollector())
- out["market_regime"] = {
- "regime": regime.get("regime"),
- "position_scale": regime.get("position_scale"),
- "allow_buys": regime.get("allow_buys"),
- }
+ if not resolve_market_regime_config(config).get("enabled"):
+ # 필터가 꺼져 있으면 check_market_regime은 매수 허용용 기본값(상승)을 돌려준다.
+ # 그걸 그대로 '상승 추세'로 보여 주면 시장과 무관하게 늘 초록불이다.
+ out["market_regime"] = {"regime": "disabled", "position_scale": None, "allow_buys": None}
+ else:
+ regime = check_market_regime(config, DataCollector())
+ out["market_regime"] = {
+ "regime": regime.get("regime"),
+ "position_scale": regime.get("position_scale"),
+ "allow_buys": regime.get("allow_buys"),
+ }
except Exception as exc:
logger.debug("get_runtime_json market_regime: {}", exc)
@@ -333,9 +454,43 @@ def get_runtime_json() -> dict:
logger.debug("get_runtime_json read_state: {}", exc)
out["signals_today"] = None
+ out.update(_scheduler_freshness(out))
return out
+def _scheduler_freshness(runtime: dict, now: Optional[datetime] = None) -> dict:
+ """상시 스케줄러를 쓰는 경우에만, 장중에 루프가 멈췄는지 판정한다.
+
+ 이 배포의 매매는 일일 CLI 사이클이 한다(스케줄러 없음). 스케줄러 기록이 최근
+ 7일 안에 없으면 '사용 안 함'으로 보고 경보하지 않는다. 쓰는 중이면 장이 열려
+ 있는 동안 60분 넘게 루프 기록이 없을 때만 멈춘 것으로 본다(밤·주말·휴장일 제외).
+ """
+ from datetime import timedelta as _td
+ from core.trading_hours import _now_kst
+
+ now = now or _now_kst()
+
+ def _parse(value):
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(str(value))
+ except ValueError:
+ return None
+ return parsed.replace(tzinfo=None)
+
+ loop = runtime.get("loop_metrics") or {}
+ last = _parse(loop.get("last_success")) or _parse(runtime.get("runtime_file_updated_at"))
+ in_use = last is not None and now - last <= _td(days=7)
+ stale = False
+ if in_use:
+ try:
+ stale = bool(_calendar().is_market_open(now)) and now - last > _td(minutes=60)
+ except Exception as exc:
+ logger.warning("스케줄러 멈춤 여부 계산 실패: {}", exc)
+ return {"scheduler_in_use": in_use, "scheduler_stale": stale}
+
+
def _html_page() -> str:
"""파일 기반 템플릿을 읽어 UI와 Python 데이터 계층을 분리한다."""
return _TEMPLATE_PATH.read_text(encoding="utf-8")
@@ -398,8 +553,38 @@ async def handle_api_baskets(_request: web.Request) -> web.Response:
)
+_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
+
+
+def _is_local_origin(request) -> bool:
+ """Host가 루프백이고, Origin/Referer가 있으면 같은 호스트여야 한다.
+
+ 커스텀 헤더만으로는 DNS 리바인딩(외부 도메인이 127.0.0.1을 가리키게 하는 공격)을
+ 막지 못한다 — 그 경우 브라우저는 같은 출처로 보고 헤더를 붙여 보낸다. Host가
+ 루프백이 아니면 이 대시보드로 온 요청이 아니다. 포트는 테스트·캡처마다 달라 보지 않는다.
+ """
+ from urllib.parse import urlsplit
+
+ host = (request.host or "").rsplit(":", 1)[0] if not (request.host or "").startswith("[") \
+ else (request.host or "").split("]", 1)[0] + "]"
+ if host.lower() not in _LOOPBACK_HOSTS:
+ return False
+ for header in ("Origin", "Referer"):
+ value = request.headers.get(header)
+ if value:
+ origin_host = (urlsplit(value).hostname or "").lower()
+ if origin_host not in {h.strip("[]") for h in _LOOPBACK_HOSTS}:
+ return False
+ return True
+
+
async def handle_api_deposit(request: web.Request) -> web.Response:
"""적립금 기록. 커스텀 헤더로 cross-site 브라우저 요청을 차단한다."""
+ if not _is_local_origin(request):
+ return web.json_response(
+ {"ok": False, "error": "이 컴퓨터에서 연 대시보드에서만 기록할 수 있습니다"},
+ status=403,
+ )
if request.headers.get("X-Requested-With") != "quant-dashboard":
return web.json_response(
{"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"},
@@ -418,8 +603,14 @@ async def handle_api_deposit(request: web.Request) -> web.Response:
)
try:
+ from core.basket_rebalancer import BasketRebalancer
from tools.record_deposit import record_basket_deposit
+ basket_name = str(body.get("basket") or "")
+ if basket_name not in BasketRebalancer.get_enabled_baskets():
+ return web.json_response(
+ {"ok": False, "error": "운용 중인 계좌가 아닙니다"}, status=400
+ )
result = record_basket_deposit(
str(body.get("basket") or ""),
body.get("amount"),
@@ -459,8 +650,10 @@ async def handle_api_cash_flows(request: web.Request) -> web.Response:
{
"basket": basket,
"mode": ledger_mode,
+ # 차트·CSV의 누적 원금은 전체 기록으로 계산해야 한다(12건 제한이면
+ # 13번째 적립부터 원금이 적게 잡힌다).
"flows": await asyncio.to_thread(
- get_recent_cash_flows, account_key, mode=ledger_mode
+ get_recent_cash_flows, account_key, None, ledger_mode
),
}
)
@@ -541,18 +734,27 @@ def _collect_all() -> dict:
evaluations = []
for name in BasketRebalancer.get_enabled_baskets():
- result, basket_name = collect_basket_paper_evaluation(
- include_benchmark=False,
- basket_name=name,
- )
+ try:
+ result, basket_name = collect_basket_paper_evaluation(
+ include_benchmark=False,
+ basket_name=name,
+ )
+ except Exception as exc:
+ # 한 계좌의 실패가 다른 계좌의 판정까지 가리지 않게 따로 남긴다
+ logger.warning("바스켓 '{}' 검증 상태 조회 실패: {}", name, exc)
+ evaluations.append({"basket": name, "error": "검증 상태를 확인할 수 없습니다"})
+ continue
evaluations.append(
{
"basket": basket_name,
"verdict": result.get("verdict"),
"paper_only": bool(result.get("paper_only", False)),
+ "review_note": result.get("review_note"),
"progress_days": result.get("progress_days"),
"min_trading_days": result.get("min_trading_days"),
"snapshot_coverage": result.get("snapshot_coverage"),
+ "measured_coverage": result.get("measured_coverage"),
+ "reconstructed_days": result.get("reconstructed_days", 0),
"issues": result.get("issues", []),
}
)
diff --git a/quant_trader_design.md b/quant_trader_design.md
index dc661f5f..af4f35f9 100644
--- a/quant_trader_design.md
+++ b/quant_trader_design.md
@@ -437,7 +437,7 @@ STEP 2에서 찾은 가중치를 `strategies.yaml`에 반영한 뒤 실행합니
### 4.3 추세 추종 전략 (중급 ⭐⭐)
- **구현**: `strategies/trend_following.py`
-- **설정**: `trend_following` (adx_threshold, trend_ma_period, atr_stop_multiplier, trailing_atr_multiplier)
+- **설정**: `trend_following` (adx_threshold, trend_ma_period). 손절·트레일링 배수는 `risk_params.yaml`의 `stop_loss.atr_multiplier` / `trailing_stop.atr_multiplier`를 따른다
- **이용(가정)하는 시장 비효율성**: **모멘텀 효과(Momentum)** — "좋은 주식이 일정 기간 계속 좋다"는 현상. 상대적으로 강한 추세가 지속되는 구간에서 추세를 따라가는 방식으로, 미국(나스닥) 등에서 **모멘텀 팩터**로 실증된 비효율성에 기반합니다. 한국 시장에서는 추세 지속성이 약해 해당 비효율성이 weaker할 수 있습니다(아래 "한국 시장 추세 지속성" 참고).
**로직**: ADX > adx_threshold, 가격 > trend_ma(200일), MACD 골든크로스(히스토그램 양수 전환) 시 매수. ATR 기반 손절·트레일링 스탑.
@@ -745,7 +745,7 @@ STEP 2에서 찾은 가중치를 `strategies.yaml`에 반영한 뒤 실행합니
### 5.7 MDD 제한
-- **설정**: `drawdown.max_portfolio_mdd`, `max_daily_loss`, `recovery_scale`
+- **설정**: `drawdown.max_portfolio_mdd`, `max_daily_loss` (회복 사이징 `recovery_scale`은 구현되지 않아 2026-09-23 삭제)
### 5.8 전략 성과 열화 감지
@@ -863,7 +863,7 @@ STEP 2에서 찾은 가중치를 `strategies.yaml`에 반영한 뒤 실행합니
### 5.16 시장 국면 적응형 전략 파라미터 (`regime_adaptive`) — v3.0
- **목적**: `check_market_regime()` 결과(bullish / caution / bearish)에 따라 **손절·익절 배수**를 바꿔 하락장에서 손실 속도를 줄이고 익절을 빨리 가져감.
-- **설정**: `config/strategies.yaml` → `regime_adaptive` (`enabled`, `bullish` / `caution` / `bearish` 각각 `buy_threshold_offset`, `stop_loss_multiplier`, `take_profit_multiplier`)
+- **설정**: `config/strategies.yaml` → `regime_adaptive` (`enabled`, `bullish` / `caution` / `bearish` 각각 `stop_loss_multiplier`, `take_profit_multiplier`). 매수 진입 기준은 국면에 따라 바뀌지 않는다 — 국면별 매수 억제는 `market_regime_filter`의 `allow_buys`·`position_scale`이 맡는다
- **구현**: `core/market_regime.py` → `get_regime_adjusted_params(config, collector)`
**OrderExecutor**가 매수 시 `calculate_stop_loss` / `calculate_take_profit`에 국면 배수 전달.
@@ -933,8 +933,8 @@ quant_trader/
│ ├── risk_manager.py # 포지션 사이징(1% 룰·신호 강도 스케일), check_diversification(업종), **check_correlation_risk**, check_recent_performance, 손절/익절/트레일링(국면 배수), 거래비용
│ ├── order_executor.py # 매수/매도. 국면 손절·익절, 상관 축소, **갭업 매수 차단**, 유동성·어닝·분산, Dead-letter
│ ├── portfolio_manager.py # 보유 포지션·잔고·수익률. sync_with_broker(KIS 잔고↔DB 크로스체크), save_daily_snapshot()
-│ ├── basket_rebalancer.py # 바스켓 리밸런싱: 목표 비중 vs 실제 비중 드리프트 감지, 주문 생성·실행, 신호 가중 모드, 스케줄러 장전 자동 통합
-│ ├── scheduler.py # 장전/장중(10분)/장마감. **갭다운 즉시 청산**, 동적 손절 갱신, auto_entry 시 장중 재스캔, 블랙스완 recovery, 바스켓 리밸런싱, paper 실전 전환 평가
+│ ├── basket_rebalancer.py # 바스켓 리밸런싱: 목표 비중 vs 실제 비중 드리프트 감지, 주문 생성·실행, 신호 가중 모드. 실행은 일일 CLI(--mode rebalance)에서만
+│ ├── scheduler.py # 장전/장중(10분)/장마감. **갭다운 즉시 청산**, 동적 손절 갱신, auto_entry 시 장중 재스캔, 블랙스완 recovery, paper 실전 전환 평가 (바스켓은 거래하지 않음)
│ ├── runtime_lock.py # `data/.scheduler.lock` — schedule 모드 단일 인스턴스(중복 실행 방지)
│ ├── trading_hours.py # 한국 장·휴장일(holidays.yaml → pykrx → fallback). 미국: us_holidays.yaml + 동부 09:30~16:00 (`is_us_trading_day` 등)
│ ├── holidays_updater.py # 휴장일 YAML 자동 갱신 (pykrx 또는 fallback)
@@ -1353,7 +1353,7 @@ quant_trader/
- [x] KIS 호출 제어 강화 — 지수 백오프+지터, SSL/커넥션 에러 전용 핸들러, 토큰 오류 쿨다운 (§9.1)
- [x] 주문 실패 Dead-letter 큐 — FailedOrder 테이블에 실패 주문 영구 저장, 재처리 지원 (§9.1)
- [x] 전략 등록 레지스트리(플러그인형) — `strategies/__init__.py`에서 `create_strategy(name)` 호출로 전략 동적 로딩 (§4.5)
-- [x] 바스켓 포트폴리오 리밸런싱 — `BasketRebalancer`로 종목별 목표 비중 관리, 드리프트/주기 기반 리밸런싱, 신호 가중 모드 지원. `--mode rebalance --basket ` CLI 및 스케줄러 장전 단계 자동 통합 (§10)
+- [x] 바스켓 포트폴리오 리밸런싱 — `BasketRebalancer`로 종목별 목표 비중 관리, 드리프트/주기 기반 리밸런싱, 신호 가중 모드 지원. `--mode rebalance --basket ` 일일 CLI로 실행 (§10). 스케줄러 장전 경로는 2026-09에 제거
- [x] **`--mode schedule`** — 모의 매매 전용 무한 스케줄 루프, `core/runtime_lock.py`로 단일 인스턴스 락
- [x] **미국 티커·장시간** — `DataCollector.fetch_stock` 미국 분기, `config/us_holidays.yaml`, `TradingHours` NYSE 구간
- [x] **DART(선택)** — `dart_loader` + `earnings_filter` 폴백, `DART_API_KEY` / `settings.dart`
diff --git a/reports/research/risk_overlay_backtest.json b/reports/research/risk_overlay_backtest.json
index bcf024e3..72b6585b 100644
--- a/reports/research/risk_overlay_backtest.json
+++ b/reports/research/risk_overlay_backtest.json
@@ -5,9 +5,10 @@
"end": "2026-09-16",
"results": {
"static": {
- "label": "고정 비중(현행)",
+ "label": "고정 비중",
"years": 24.7,
"cagr_pct": 7.45,
+ "period_return_pct": 489.65,
"vol_pct": 12.15,
"sharpe": 0.42,
"mdd_pct": -28.75,
@@ -15,14 +16,14 @@
"worst_year_pct": -18.67,
"losing_years": 7,
"total_years": 25,
- "negative_months_pct": 40.5,
+ "negative_months_pct": 40.4,
"avg_exposure_pct": 51.3,
"turnover_per_year_pct": 8.8,
- "final_value": 91098365,
+ "final_value": 91101012,
"contributed": 29900000,
- "profit": 61198365,
+ "profit": 61201012,
"yearly": {
- "2002": -4.11,
+ "2002": -4.13,
"2003": 16.82,
"2004": 6.62,
"2005": 26.76,
@@ -53,6 +54,7 @@
"label": "추세 필터 · 200일선 아래면 절반",
"years": 24.7,
"cagr_pct": 7.19,
+ "period_return_pct": 455.74,
"vol_pct": 10.14,
"sharpe": 0.45,
"mdd_pct": -21.8,
@@ -60,15 +62,15 @@
"worst_year_pct": -12.27,
"losing_years": 7,
"total_years": 25,
- "negative_months_pct": 40.9,
+ "negative_months_pct": 40.7,
"avg_exposure_pct": 41.1,
"turnover_per_year_pct": 54.2,
- "final_value": 88131441,
+ "final_value": 88134581,
"contributed": 29900000,
- "profit": 58231441,
+ "profit": 58234581,
"yearly": {
- "2002": -2.48,
- "2003": 16.92,
+ "2002": -2.5,
+ "2003": 16.93,
"2004": 4.39,
"2005": 26.74,
"2006": 1.3,
@@ -85,12 +87,12 @@
"2017": 13.71,
"2018": -4.4,
"2019": 4.49,
- "2020": 17.53,
+ "2020": 17.52,
"2021": 3.51,
- "2022": -4.95,
+ "2022": -4.96,
"2023": 7.02,
"2024": -5.42,
- "2025": 39.81,
+ "2025": 39.82,
"2026": 40.97
}
},
@@ -98,6 +100,7 @@
"label": "추세 필터 · 200일선 아래면 0",
"years": 24.7,
"cagr_pct": 6.79,
+ "period_return_pct": 406.57,
"vol_pct": 9.43,
"sharpe": 0.44,
"mdd_pct": -21.8,
@@ -105,21 +108,21 @@
"worst_year_pct": -6.76,
"losing_years": 10,
"total_years": 25,
- "negative_months_pct": 29.4,
+ "negative_months_pct": 29.3,
"avg_exposure_pct": 31.4,
"turnover_per_year_pct": 101.7,
- "final_value": 82972040,
+ "final_value": 82978993,
"contributed": 29900000,
- "profit": 53072040,
+ "profit": 53078993,
"yearly": {
- "2002": -1.0,
+ "2002": -1.02,
"2003": 16.68,
"2004": 1.95,
"2005": 26.71,
"2006": -1.34,
"2007": 16.34,
- "2008": -4.23,
- "2009": 16.06,
+ "2008": -4.24,
+ "2009": 16.07,
"2010": 9.67,
"2011": -1.63,
"2012": -5.64,
@@ -133,9 +136,9 @@
"2020": 15.14,
"2021": 4.41,
"2022": 2.93,
- "2023": 1.29,
+ "2023": 1.3,
"2024": -6.76,
- "2025": 36.8,
+ "2025": 36.82,
"2026": 40.96
}
},
@@ -143,6 +146,7 @@
"label": "변동성 목표 15%",
"years": 24.7,
"cagr_pct": 5.79,
+ "period_return_pct": 302.07,
"vol_pct": 8.18,
"sharpe": 0.38,
"mdd_pct": -17.82,
@@ -150,14 +154,14 @@
"worst_year_pct": -12.33,
"losing_years": 6,
"total_years": 25,
- "negative_months_pct": 40.5,
+ "negative_months_pct": 40.4,
"avg_exposure_pct": 40.9,
"turnover_per_year_pct": 58.3,
- "final_value": 64275336,
+ "final_value": 64277749,
"contributed": 29900000,
- "profit": 34375336,
+ "profit": 34377749,
"yearly": {
- "2002": 5.39,
+ "2002": 5.37,
"2003": 10.83,
"2004": 4.96,
"2005": 22.86,
@@ -188,6 +192,7 @@
"label": "변동성 목표 20%",
"years": 24.7,
"cagr_pct": 6.32,
+ "period_return_pct": 353.99,
"vol_pct": 9.38,
"sharpe": 0.39,
"mdd_pct": -20.86,
@@ -195,14 +200,14 @@
"worst_year_pct": -14.71,
"losing_years": 6,
"total_years": 25,
- "negative_months_pct": 40.2,
+ "negative_months_pct": 40.1,
"avg_exposure_pct": 46.1,
"turnover_per_year_pct": 30.2,
- "final_value": 70835709,
+ "final_value": 70836842,
"contributed": 29900000,
- "profit": 40935709,
+ "profit": 40936842,
"yearly": {
- "2002": 3.41,
+ "2002": 3.39,
"2003": 12.8,
"2004": 6.29,
"2005": 25.89,
@@ -211,7 +216,7 @@
"2008": -14.71,
"2009": 18.35,
"2010": 12.66,
- "2011": -5.3,
+ "2011": -5.31,
"2012": 5.23,
"2013": 1.66,
"2014": -2.41,
@@ -232,97 +237,146 @@
"dd10": {
"label": "낙폭 제어 · -10%에서 절반",
"years": 24.7,
- "cagr_pct": 6.91,
- "vol_pct": 10.69,
- "sharpe": 0.41,
- "mdd_pct": -23.77,
- "calmar": 0.29,
- "worst_year_pct": -14.64,
- "losing_years": 7,
+ "cagr_pct": 6.06,
+ "period_return_pct": 327.65,
+ "vol_pct": 9.19,
+ "sharpe": 0.37,
+ "mdd_pct": -19.39,
+ "calmar": 0.31,
+ "worst_year_pct": -12.45,
+ "losing_years": 9,
"total_years": 25,
- "negative_months_pct": 39.9,
- "avg_exposure_pct": 48.2,
- "turnover_per_year_pct": 32.4,
- "final_value": 80557524,
+ "negative_months_pct": 38.4,
+ "avg_exposure_pct": 38.7,
+ "turnover_per_year_pct": 38.2,
+ "final_value": 73459024,
"contributed": 29900000,
- "profit": 50657524,
+ "profit": 43559024,
"yearly": {
- "2002": -4.11,
- "2003": 16.82,
- "2004": 6.62,
- "2005": 26.76,
- "2006": 3.86,
- "2007": 16.37,
- "2008": -14.64,
- "2009": 23.61,
- "2010": 12.58,
- "2011": -5.14,
- "2012": 6.99,
- "2013": 1.66,
+ "2002": -1.06,
+ "2003": 9.85,
+ "2004": 2.63,
+ "2005": 25.46,
+ "2006": 3.9,
+ "2007": 16.35,
+ "2008": -12.45,
+ "2009": 17.06,
+ "2010": 12.6,
+ "2011": -4.89,
+ "2012": 4.85,
+ "2013": -1.12,
"2014": -2.43,
- "2015": 0.83,
- "2016": 5.52,
- "2017": 13.81,
- "2018": -8.76,
- "2019": 7.67,
- "2020": 12.69,
- "2021": 2.59,
- "2022": -9.97,
- "2023": 9.22,
- "2024": -4.24,
- "2025": 39.54,
- "2026": 31.8
+ "2015": -0.89,
+ "2016": 4.23,
+ "2017": 12.54,
+ "2018": -8.46,
+ "2019": 5.34,
+ "2020": 14.78,
+ "2021": 2.9,
+ "2022": -7.25,
+ "2023": 7.84,
+ "2024": -4.33,
+ "2025": 36.23,
+ "2026": 31.9
}
},
"trend50_dd10": {
"label": "추세 절반 + 낙폭 제어",
"years": 24.7,
- "cagr_pct": 6.66,
- "vol_pct": 9.46,
- "sharpe": 0.43,
- "mdd_pct": -20.26,
- "calmar": 0.33,
- "worst_year_pct": -13.06,
- "losing_years": 7,
+ "cagr_pct": 6.58,
+ "period_return_pct": 382.69,
+ "vol_pct": 8.71,
+ "sharpe": 0.45,
+ "mdd_pct": -16.53,
+ "calmar": 0.4,
+ "worst_year_pct": -8.71,
+ "losing_years": 8,
"total_years": 25,
- "negative_months_pct": 41.2,
- "avg_exposure_pct": 40.6,
- "turnover_per_year_pct": 68.2,
- "final_value": 79158022,
+ "negative_months_pct": 38.4,
+ "avg_exposure_pct": 36.0,
+ "turnover_per_year_pct": 61.7,
+ "final_value": 80484835,
"contributed": 29900000,
- "profit": 49258022,
+ "profit": 50584835,
"yearly": {
- "2002": -2.48,
- "2003": 16.92,
- "2004": 4.39,
- "2005": 26.74,
- "2006": 1.3,
- "2007": 16.36,
- "2008": -13.06,
- "2009": 20.67,
- "2010": 11.1,
- "2011": -3.14,
- "2012": 0.7,
- "2013": 0.59,
+ "2002": -0.27,
+ "2003": 10.47,
+ "2004": 4.42,
+ "2005": 26.7,
+ "2006": 1.32,
+ "2007": 16.35,
+ "2008": -8.71,
+ "2009": 14.06,
+ "2010": 11.16,
+ "2011": -3.94,
+ "2012": 1.87,
+ "2013": 1.39,
"2014": -2.59,
+ "2015": -0.42,
+ "2016": 3.87,
+ "2017": 13.67,
+ "2018": -4.38,
+ "2019": 4.49,
+ "2020": 10.68,
+ "2021": 3.84,
+ "2022": -4.95,
+ "2023": 7.02,
+ "2024": -5.41,
+ "2025": 39.81,
+ "2026": 38.32
+ }
+ },
+ "trend50_dd10_min": {
+ "label": "추세·낙폭 중 더 낮은 비중",
+ "years": 24.7,
+ "cagr_pct": 6.72,
+ "period_return_pct": 399.02,
+ "vol_pct": 9.14,
+ "sharpe": 0.44,
+ "mdd_pct": -19.22,
+ "calmar": 0.35,
+ "worst_year_pct": -12.26,
+ "losing_years": 7,
+ "total_years": 25,
+ "negative_months_pct": 40.4,
+ "avg_exposure_pct": 38.5,
+ "turnover_per_year_pct": 60.5,
+ "final_value": 84121940,
+ "contributed": 29900000,
+ "profit": 54221940,
+ "yearly": {
+ "2002": -1.06,
+ "2003": 9.85,
+ "2004": 4.41,
+ "2005": 26.69,
+ "2006": 1.32,
+ "2007": 16.35,
+ "2008": -12.26,
+ "2009": 17.06,
+ "2010": 11.16,
+ "2011": -3.13,
+ "2012": 0.5,
+ "2013": 0.64,
+ "2014": -1.79,
"2015": 0.36,
- "2016": 5.08,
- "2017": 13.71,
+ "2016": 3.87,
+ "2017": 13.7,
"2018": -4.39,
"2019": 4.49,
- "2020": 12.83,
+ "2020": 17.52,
"2021": 3.51,
"2022": -4.95,
"2023": 7.02,
"2024": -5.42,
- "2025": 39.81,
- "2026": 31.8
+ "2025": 39.82,
+ "2026": 38.33
}
},
"trend50_vol20": {
"label": "추세 절반 + 변동성 20%",
"years": 24.7,
"cagr_pct": 6.22,
+ "period_return_pct": 344.2,
"vol_pct": 7.9,
"sharpe": 0.44,
"mdd_pct": -13.79,
@@ -330,14 +384,14 @@
"worst_year_pct": -8.92,
"losing_years": 7,
"total_years": 25,
- "negative_months_pct": 40.5,
+ "negative_months_pct": 40.4,
"avg_exposure_pct": 37.4,
"turnover_per_year_pct": 68.9,
- "final_value": 70273594,
+ "final_value": 70270133,
"contributed": 29900000,
- "profit": 40373594,
+ "profit": 40370133,
"yearly": {
- "2002": 4.41,
+ "2002": 4.39,
"2003": 13.18,
"2004": 3.95,
"2005": 25.88,
@@ -347,7 +401,7 @@
"2009": 16.0,
"2010": 11.21,
"2011": -3.43,
- "2012": -0.46,
+ "2012": -0.45,
"2013": 0.6,
"2014": -2.59,
"2015": 0.36,
@@ -359,7 +413,7 @@
"2021": 3.48,
"2022": -4.73,
"2023": 7.06,
- "2024": -5.11,
+ "2024": -5.12,
"2025": 34.41,
"2026": 22.46
}
@@ -369,12 +423,13 @@
"pocket_etf": {
"symbol": "069500",
"start": "2014-06-30",
- "end": "2026-09-17",
+ "end": "2026-09-16",
"results": {
"static": {
- "label": "고정 비중(현행)",
- "years": 12.22,
- "cagr_pct": 9.62,
+ "label": "고정 비중",
+ "years": 12.21,
+ "cagr_pct": 9.59,
+ "period_return_pct": 206.13,
"vol_pct": 12.06,
"sharpe": 0.59,
"mdd_pct": -21.73,
@@ -382,32 +437,33 @@
"worst_year_pct": -11.21,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 41.5,
+ "negative_months_pct": 41.9,
"avg_exposure_pct": 50.9,
- "turnover_per_year_pct": 16.6,
- "final_value": 35522386,
+ "turnover_per_year_pct": 16.7,
+ "final_value": 35409900,
"contributed": 15000000,
- "profit": 20522386,
+ "profit": 20409900,
"yearly": {
- "2014": -1.57,
+ "2014": -1.59,
"2015": 1.53,
"2016": 6.54,
"2017": 14.43,
"2018": -7.34,
"2019": 8.81,
"2020": 21.17,
- "2021": 3.1,
+ "2021": 3.11,
"2022": -11.21,
"2023": 13.89,
"2024": -3.35,
"2025": 44.44,
- "2026": 41.78
+ "2026": 41.33
}
},
"trend50": {
"label": "추세 필터 · 200일선 아래면 절반",
- "years": 12.22,
- "cagr_pct": 9.32,
+ "years": 12.21,
+ "cagr_pct": 9.29,
+ "period_return_pct": 195.84,
"vol_pct": 10.85,
"sharpe": 0.62,
"mdd_pct": -21.66,
@@ -415,80 +471,82 @@
"worst_year_pct": -4.58,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 40.1,
- "avg_exposure_pct": 39.5,
- "turnover_per_year_pct": 57.4,
- "final_value": 34630217,
+ "negative_months_pct": 40.5,
+ "avg_exposure_pct": 39.4,
+ "turnover_per_year_pct": 57.5,
+ "final_value": 34514834,
"contributed": 15000000,
- "profit": 19630217,
+ "profit": 19514834,
"yearly": {
- "2014": -1.81,
+ "2014": -1.83,
"2015": 0.7,
"2016": 6.05,
"2017": 14.43,
"2018": -3.49,
- "2019": 5.34,
+ "2019": 5.33,
"2020": 18.43,
"2021": 3.91,
"2022": -4.28,
- "2023": 7.87,
+ "2023": 7.85,
"2024": -4.58,
- "2025": 40.76,
- "2026": 42.62
+ "2025": 40.77,
+ "2026": 42.17
}
},
"trend0": {
"label": "추세 필터 · 200일선 아래면 0",
- "years": 12.22,
- "cagr_pct": 8.86,
- "vol_pct": 10.41,
+ "years": 12.21,
+ "cagr_pct": 8.84,
+ "period_return_pct": 181.26,
+ "vol_pct": 10.42,
"sharpe": 0.6,
"mdd_pct": -21.66,
"calmar": 0.41,
"worst_year_pct": -6.09,
"losing_years": 3,
"total_years": 13,
- "negative_months_pct": 25.9,
+ "negative_months_pct": 26.4,
"avg_exposure_pct": 28.1,
- "turnover_per_year_pct": 100.8,
- "final_value": 33369808,
+ "turnover_per_year_pct": 100.9,
+ "final_value": 33266744,
"contributed": 15000000,
- "profit": 18369808,
+ "profit": 18266744,
"yearly": {
- "2014": -2.08,
+ "2014": -2.1,
"2015": -0.23,
"2016": 5.54,
"2017": 14.43,
- "2018": 0.31,
+ "2018": 0.3,
"2019": 1.8,
"2020": 15.99,
"2021": 4.47,
"2022": 2.93,
"2023": 2.09,
"2024": -6.09,
- "2025": 37.35,
- "2026": 42.61
+ "2025": 37.37,
+ "2026": 42.15
}
},
"vol15": {
"label": "변동성 목표 15%",
- "years": 12.22,
- "cagr_pct": 6.59,
+ "years": 12.21,
+ "cagr_pct": 6.58,
+ "period_return_pct": 117.71,
"vol_pct": 8.15,
- "sharpe": 0.48,
+ "sharpe": 0.47,
"mdd_pct": -15.65,
"calmar": 0.42,
"worst_year_pct": -9.97,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 40.8,
+ "negative_months_pct": 41.2,
"avg_exposure_pct": 43.7,
- "turnover_per_year_pct": 69.1,
- "final_value": 26260789,
+ "turnover_per_year_pct": 69.2,
+ "final_value": 26217419,
"contributed": 15000000,
- "profit": 11260789,
+ "profit": 11217419,
"yearly": {
- "2014": -1.57,
+ "2014": -1.59,
"2015": 1.53,
"2016": 6.43,
"2017": 14.43,
@@ -500,13 +558,14 @@
"2023": 11.56,
"2024": -3.26,
"2025": 29.63,
- "2026": 21.71
+ "2026": 21.5
}
},
"vol20": {
"label": "변동성 목표 20%",
- "years": 12.22,
- "cagr_pct": 7.44,
+ "years": 12.21,
+ "cagr_pct": 7.43,
+ "period_return_pct": 139.84,
"vol_pct": 9.14,
"sharpe": 0.52,
"mdd_pct": -17.0,
@@ -514,14 +573,14 @@
"worst_year_pct": -10.96,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 40.8,
+ "negative_months_pct": 41.2,
"avg_exposure_pct": 47.6,
"turnover_per_year_pct": 36.0,
- "final_value": 28779405,
+ "final_value": 28732150,
"contributed": 15000000,
- "profit": 13779405,
+ "profit": 13732150,
"yearly": {
- "2014": -1.57,
+ "2014": -1.59,
"2015": 1.53,
"2016": 6.54,
"2017": 14.43,
@@ -533,111 +592,410 @@
"2023": 13.9,
"2024": -3.17,
"2025": 38.27,
- "2026": 24.13
+ "2026": 23.92
}
},
"dd10": {
"label": "낙폭 제어 · -10%에서 절반",
- "years": 12.22,
- "cagr_pct": 8.84,
- "vol_pct": 10.68,
- "sharpe": 0.59,
- "mdd_pct": -17.29,
- "calmar": 0.51,
- "worst_year_pct": -11.2,
+ "years": 12.21,
+ "cagr_pct": 8.08,
+ "period_return_pct": 158.25,
+ "vol_pct": 9.83,
+ "sharpe": 0.56,
+ "mdd_pct": -16.26,
+ "calmar": 0.5,
+ "worst_year_pct": -7.51,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 40.8,
- "avg_exposure_pct": 49.9,
- "turnover_per_year_pct": 42.9,
- "final_value": 33256706,
+ "negative_months_pct": 41.2,
+ "avg_exposure_pct": 42.7,
+ "turnover_per_year_pct": 55.1,
+ "final_value": 31136106,
"contributed": 15000000,
- "profit": 18256706,
+ "profit": 16136106,
"yearly": {
- "2014": -1.57,
+ "2014": -1.59,
"2015": 1.53,
"2016": 6.54,
"2017": 14.43,
- "2018": -7.34,
- "2019": 8.81,
- "2020": 14.98,
+ "2018": -7.51,
+ "2019": 6.52,
+ "2020": 13.08,
"2021": 3.26,
- "2022": -11.2,
- "2023": 13.89,
- "2024": -3.34,
- "2025": 44.4,
- "2026": 36.63
+ "2022": -7.51,
+ "2023": 9.17,
+ "2024": -3.17,
+ "2025": 37.82,
+ "2026": 36.76
}
},
"trend50_dd10": {
"label": "추세 절반 + 낙폭 제어",
- "years": 12.22,
- "cagr_pct": 8.76,
- "vol_pct": 9.47,
+ "years": 12.21,
+ "cagr_pct": 8.65,
+ "period_return_pct": 175.32,
+ "vol_pct": 9.27,
"sharpe": 0.64,
- "mdd_pct": -16.26,
- "calmar": 0.54,
+ "mdd_pct": -16.42,
+ "calmar": 0.53,
+ "worst_year_pct": -4.57,
+ "losing_years": 4,
+ "total_years": 13,
+ "negative_months_pct": 39.9,
+ "avg_exposure_pct": 38.2,
+ "turnover_per_year_pct": 72.2,
+ "final_value": 32804251,
+ "contributed": 15000000,
+ "profit": 17804251,
+ "yearly": {
+ "2014": -1.83,
+ "2015": 0.7,
+ "2016": 6.05,
+ "2017": 14.43,
+ "2018": -3.49,
+ "2019": 5.33,
+ "2020": 12.72,
+ "2021": 3.92,
+ "2022": -4.27,
+ "2023": 7.87,
+ "2024": -4.57,
+ "2025": 40.75,
+ "2026": 38.96
+ }
+ },
+ "trend50_dd10_min": {
+ "label": "추세·낙폭 중 더 낮은 비중",
+ "years": 12.21,
+ "cagr_pct": 9.08,
+ "period_return_pct": 189.17,
+ "vol_pct": 9.41,
+ "sharpe": 0.68,
+ "mdd_pct": -16.42,
+ "calmar": 0.55,
"worst_year_pct": -4.58,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 39.5,
+ "negative_months_pct": 39.9,
"avg_exposure_pct": 38.7,
- "turnover_per_year_pct": 82.6,
- "final_value": 32857861,
+ "turnover_per_year_pct": 71.0,
+ "final_value": 33748221,
"contributed": 15000000,
- "profit": 17857861,
+ "profit": 18748221,
"yearly": {
- "2014": -1.81,
+ "2014": -1.83,
"2015": 0.7,
"2016": 6.05,
"2017": 14.43,
"2018": -3.49,
- "2019": 5.34,
- "2020": 15.86,
+ "2019": 5.33,
+ "2020": 18.43,
"2021": 3.91,
- "2022": -4.27,
- "2023": 7.87,
+ "2022": -4.28,
+ "2023": 7.85,
"2024": -4.58,
- "2025": 40.75,
- "2026": 36.93
+ "2025": 40.77,
+ "2026": 38.96
}
},
"trend50_vol20": {
"label": "추세 절반 + 변동성 20%",
- "years": 12.22,
- "cagr_pct": 7.45,
+ "years": 12.21,
+ "cagr_pct": 7.43,
+ "period_return_pct": 139.98,
"vol_pct": 7.66,
"sharpe": 0.61,
"mdd_pct": -11.9,
- "calmar": 0.63,
+ "calmar": 0.62,
"worst_year_pct": -4.27,
"losing_years": 4,
"total_years": 13,
- "negative_months_pct": 39.5,
+ "negative_months_pct": 39.9,
"avg_exposure_pct": 36.5,
"turnover_per_year_pct": 69.0,
- "final_value": 28620391,
+ "final_value": 28568318,
"contributed": 15000000,
- "profit": 13620391,
+ "profit": 13568318,
"yearly": {
- "2014": -1.81,
+ "2014": -1.83,
"2015": 0.7,
"2016": 6.05,
"2017": 14.43,
"2018": -3.49,
- "2019": 5.34,
+ "2019": 5.33,
"2020": 13.93,
"2021": 3.88,
"2022": -4.09,
- "2023": 7.93,
+ "2023": 7.9,
"2024": -4.27,
"2025": 35.44,
- "2026": 24.13
+ "2026": 23.92
}
}
}
},
"basket": {
+ "symbol": "EW9",
+ "symbols": [
+ "005930",
+ "035420",
+ "005380",
+ "051910",
+ "005490",
+ "055550",
+ "035720",
+ "012330",
+ "105560"
+ ],
+ "rf_annual": 0.0,
+ "start": "2021-12-01",
+ "end": "2026-09-16",
+ "results": {
+ "static": {
+ "label": "고정 비중",
+ "years": 4.79,
+ "cagr_pct": 6.07,
+ "period_return_pct": 32.64,
+ "vol_pct": 16.47,
+ "sharpe": 0.45,
+ "mdd_pct": -19.53,
+ "calmar": 0.31,
+ "worst_year_pct": -13.42,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 59.4,
+ "turnover_per_year_pct": 14.5,
+ "final_value": 13263508,
+ "contributed": 10000000,
+ "profit": 3263508,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -13.42,
+ "2023": 13.72,
+ "2024": -4.68,
+ "2025": 28.48,
+ "2026": 10.05
+ }
+ },
+ "trend50": {
+ "label": "추세 필터 · 200일선 아래면 절반",
+ "years": 4.79,
+ "cagr_pct": 4.58,
+ "period_return_pct": 23.92,
+ "vol_pct": 14.79,
+ "sharpe": 0.39,
+ "mdd_pct": -18.09,
+ "calmar": 0.25,
+ "worst_year_pct": -12.61,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 52.0,
+ "turnover_per_year_pct": 82.5,
+ "final_value": 12391645,
+ "contributed": 10000000,
+ "profit": 2391645,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -12.61,
+ "2023": 7.25,
+ "2024": -7.98,
+ "2025": 29.21,
+ "2026": 11.24
+ }
+ },
+ "trend0": {
+ "label": "추세 필터 · 200일선 아래면 0",
+ "years": 4.79,
+ "cagr_pct": 2.44,
+ "period_return_pct": 12.26,
+ "vol_pct": 14.34,
+ "sharpe": 0.25,
+ "mdd_pct": -24.94,
+ "calmar": 0.1,
+ "worst_year_pct": -11.64,
+ "losing_years": 4,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 44.0,
+ "turnover_per_year_pct": 152.9,
+ "final_value": 11226213,
+ "contributed": 10000000,
+ "profit": 1226213,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -11.64,
+ "2023": -0.01,
+ "2024": -11.53,
+ "2025": 29.16,
+ "2026": 11.24
+ }
+ },
+ "vol15": {
+ "label": "변동성 목표 15%",
+ "years": 4.79,
+ "cagr_pct": 2.62,
+ "period_return_pct": 13.17,
+ "vol_pct": 10.7,
+ "sharpe": 0.3,
+ "mdd_pct": -15.78,
+ "calmar": 0.17,
+ "worst_year_pct": -12.12,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 51.7,
+ "avg_exposure_pct": 42.3,
+ "turnover_per_year_pct": 108.0,
+ "final_value": 11316835,
+ "contributed": 10000000,
+ "profit": 1316835,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -12.12,
+ "2023": 10.1,
+ "2024": -4.29,
+ "2025": 15.33,
+ "2026": 6.0
+ }
+ },
+ "vol20": {
+ "label": "변동성 목표 20%",
+ "years": 4.79,
+ "cagr_pct": 3.87,
+ "period_return_pct": 19.96,
+ "vol_pct": 12.93,
+ "sharpe": 0.37,
+ "mdd_pct": -18.48,
+ "calmar": 0.21,
+ "worst_year_pct": -14.14,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 50.0,
+ "avg_exposure_pct": 52.0,
+ "turnover_per_year_pct": 92.8,
+ "final_value": 11996038,
+ "contributed": 10000000,
+ "profit": 1996038,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -14.14,
+ "2023": 12.82,
+ "2024": -4.73,
+ "2025": 21.03,
+ "2026": 7.44
+ }
+ },
+ "dd10": {
+ "label": "낙폭 제어 · -10%에서 절반",
+ "years": 4.79,
+ "cagr_pct": 3.1,
+ "period_return_pct": 15.77,
+ "vol_pct": 12.1,
+ "sharpe": 0.32,
+ "mdd_pct": -15.0,
+ "calmar": 0.21,
+ "worst_year_pct": -11.05,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 39.2,
+ "turnover_per_year_pct": 57.6,
+ "final_value": 11577422,
+ "contributed": 10000000,
+ "profit": 1577422,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -11.05,
+ "2023": 7.1,
+ "2024": -5.36,
+ "2025": 19.02,
+ "2026": 7.93
+ }
+ },
+ "trend50_dd10": {
+ "label": "추세 절반 + 낙폭 제어",
+ "years": 4.79,
+ "cagr_pct": 2.68,
+ "period_return_pct": 13.5,
+ "vol_pct": 11.38,
+ "sharpe": 0.3,
+ "mdd_pct": -15.0,
+ "calmar": 0.18,
+ "worst_year_pct": -10.59,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 33.2,
+ "turnover_per_year_pct": 80.2,
+ "final_value": 11349921,
+ "contributed": 10000000,
+ "profit": 1349921,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -10.59,
+ "2023": 3.61,
+ "2024": -4.02,
+ "2025": 18.26,
+ "2026": 7.99
+ }
+ },
+ "trend50_dd10_min": {
+ "label": "추세·낙폭 중 더 낮은 비중",
+ "years": 4.79,
+ "cagr_pct": 3.1,
+ "period_return_pct": 15.77,
+ "vol_pct": 12.1,
+ "sharpe": 0.32,
+ "mdd_pct": -15.0,
+ "calmar": 0.21,
+ "worst_year_pct": -11.05,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 39.2,
+ "turnover_per_year_pct": 57.6,
+ "final_value": 11577422,
+ "contributed": 10000000,
+ "profit": 1577422,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -11.05,
+ "2023": 7.1,
+ "2024": -5.36,
+ "2025": 19.02,
+ "2026": 7.93
+ }
+ },
+ "trend50_vol20": {
+ "label": "추세 절반 + 변동성 20%",
+ "years": 4.79,
+ "cagr_pct": 2.2,
+ "period_return_pct": 11.01,
+ "vol_pct": 11.51,
+ "sharpe": 0.25,
+ "mdd_pct": -18.93,
+ "calmar": 0.12,
+ "worst_year_pct": -13.06,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 51.7,
+ "avg_exposure_pct": 44.7,
+ "turnover_per_year_pct": 140.8,
+ "final_value": 11101108,
+ "contributed": 10000000,
+ "profit": 1101108,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -13.06,
+ "2023": 5.8,
+ "2024": -7.45,
+ "2025": 21.41,
+ "2026": 7.44
+ }
+ }
+ }
+ },
+ "basket_legacy10": {
"symbol": "EW10",
"symbols": [
"005930",
@@ -651,217 +1009,362 @@
"012330",
"105560"
],
+ "rf_annual": 0.03,
"start": "2021-12-01",
- "end": "2026-09-17",
+ "end": "2026-09-16",
"results": {
"static": {
- "label": "고정 비중(현행)",
+ "label": "고정 비중",
"years": 4.79,
- "cagr_pct": 18.32,
- "vol_pct": 19.98,
- "sharpe": 0.82,
- "mdd_pct": -26.06,
- "calmar": 0.7,
- "worst_year_pct": -13.48,
+ "cagr_pct": 13.59,
+ "period_return_pct": 84.13,
+ "vol_pct": 16.86,
+ "sharpe": 0.68,
+ "mdd_pct": -17.38,
+ "calmar": 0.78,
+ "worst_year_pct": -13.49,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 40.4,
+ "negative_months_pct": 41.4,
"avg_exposure_pct": 61.0,
- "turnover_per_year_pct": 21.5,
- "final_value": 22389785,
+ "turnover_per_year_pct": 18.6,
+ "final_value": 18412540,
"contributed": 10000000,
- "profit": 12389785,
+ "profit": 8412540,
"yearly": {
- "2021": 0.84,
- "2022": -13.48,
- "2023": 19.75,
- "2024": -2.25,
- "2025": 49.54,
- "2026": 46.64
+ "2021": 0.82,
+ "2022": -13.49,
+ "2023": 19.78,
+ "2024": -0.78,
+ "2025": 41.03,
+ "2026": 25.96
}
},
"trend50": {
"label": "추세 필터 · 200일선 아래면 절반",
"years": 4.79,
- "cagr_pct": 16.36,
- "vol_pct": 19.1,
- "sharpe": 0.76,
- "mdd_pct": -24.85,
- "calmar": 0.66,
- "worst_year_pct": -12.61,
+ "cagr_pct": 11.32,
+ "period_return_pct": 67.15,
+ "vol_pct": 15.61,
+ "sharpe": 0.59,
+ "mdd_pct": -15.8,
+ "calmar": 0.72,
+ "worst_year_pct": -12.62,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 42.1,
- "avg_exposure_pct": 52.6,
- "turnover_per_year_pct": 85.5,
- "final_value": 20669841,
+ "negative_months_pct": 43.1,
+ "avg_exposure_pct": 52.2,
+ "turnover_per_year_pct": 80.9,
+ "final_value": 16715161,
"contributed": 10000000,
- "profit": 10669841,
+ "profit": 6715161,
"yearly": {
- "2021": 0.84,
- "2022": -12.61,
- "2023": 11.52,
- "2024": -6.32,
- "2025": 49.02,
- "2026": 50.69
+ "2021": 0.82,
+ "2022": -12.62,
+ "2023": 10.85,
+ "2024": -5.54,
+ "2025": 41.44,
+ "2026": 28.12
}
},
"trend0": {
"label": "추세 필터 · 200일선 아래면 0",
"years": 4.79,
- "cagr_pct": 13.92,
- "vol_pct": 18.74,
- "sharpe": 0.65,
- "mdd_pct": -24.85,
- "calmar": 0.56,
- "worst_year_pct": -11.28,
+ "cagr_pct": 8.9,
+ "period_return_pct": 50.42,
+ "vol_pct": 15.18,
+ "sharpe": 0.46,
+ "mdd_pct": -20.12,
+ "calmar": 0.44,
+ "worst_year_pct": -11.29,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 35.1,
- "avg_exposure_pct": 44.7,
- "turnover_per_year_pct": 150.4,
- "final_value": 18676094,
+ "negative_months_pct": 39.7,
+ "avg_exposure_pct": 44.3,
+ "turnover_per_year_pct": 148.6,
+ "final_value": 15042292,
"contributed": 10000000,
- "profit": 8676094,
+ "profit": 5042292,
"yearly": {
- "2021": 0.84,
- "2022": -11.28,
- "2023": 4.39,
- "2024": -9.88,
- "2025": 47.29,
- "2026": 50.69
+ "2021": 0.82,
+ "2022": -11.29,
+ "2023": 3.1,
+ "2024": -9.71,
+ "2025": 41.03,
+ "2026": 28.12
}
},
"vol15": {
"label": "변동성 목표 15%",
"years": 4.79,
- "cagr_pct": 9.7,
- "vol_pct": 11.91,
- "sharpe": 0.61,
+ "cagr_pct": 7.78,
+ "period_return_pct": 43.18,
+ "vol_pct": 10.84,
+ "sharpe": 0.49,
"mdd_pct": -15.14,
- "calmar": 0.64,
+ "calmar": 0.51,
"worst_year_pct": -11.83,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 42.1,
- "avg_exposure_pct": 41.0,
- "turnover_per_year_pct": 96.7,
- "final_value": 15579191,
+ "negative_months_pct": 44.8,
+ "avg_exposure_pct": 41.9,
+ "turnover_per_year_pct": 97.9,
+ "final_value": 14317874,
"contributed": 10000000,
- "profit": 5579191,
+ "profit": 4317874,
"yearly": {
- "2021": 0.84,
+ "2021": 0.82,
"2022": -11.83,
- "2023": 15.12,
- "2024": -3.35,
- "2025": 26.76,
- "2026": 24.28
+ "2023": 14.44,
+ "2024": -1.87,
+ "2025": 23.76,
+ "2026": 15.9
}
},
"vol20": {
"label": "변동성 목표 20%",
"years": 4.79,
- "cagr_pct": 12.4,
- "vol_pct": 14.17,
- "sharpe": 0.71,
+ "cagr_pct": 9.45,
+ "period_return_pct": 54.11,
+ "vol_pct": 13.11,
+ "sharpe": 0.55,
"mdd_pct": -17.98,
- "calmar": 0.69,
+ "calmar": 0.53,
"worst_year_pct": -14.23,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 42.1,
- "avg_exposure_pct": 51.1,
- "turnover_per_year_pct": 100.1,
- "final_value": 17511837,
+ "negative_months_pct": 43.1,
+ "avg_exposure_pct": 51.5,
+ "turnover_per_year_pct": 101.0,
+ "final_value": 15411410,
"contributed": 10000000,
- "profit": 7511837,
+ "profit": 5411410,
"yearly": {
- "2021": 0.84,
+ "2021": 0.82,
"2022": -14.23,
- "2023": 17.82,
- "2024": -3.65,
- "2025": 39.19,
- "2026": 28.17
+ "2023": 17.74,
+ "2024": -1.71,
+ "2025": 32.61,
+ "2026": 16.14
}
},
"dd10": {
"label": "낙폭 제어 · -10%에서 절반",
"years": 4.79,
- "cagr_pct": 15.84,
- "vol_pct": 15.71,
- "sharpe": 0.85,
- "mdd_pct": -16.41,
- "calmar": 0.97,
+ "cagr_pct": 10.7,
+ "period_return_pct": 62.74,
+ "vol_pct": 13.48,
+ "sharpe": 0.62,
+ "mdd_pct": -13.84,
+ "calmar": 0.77,
"worst_year_pct": -10.62,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 38.6,
- "avg_exposure_pct": 43.3,
- "turnover_per_year_pct": 78.4,
- "final_value": 20228356,
+ "negative_months_pct": 39.7,
+ "avg_exposure_pct": 43.6,
+ "turnover_per_year_pct": 73.8,
+ "final_value": 16273695,
"contributed": 10000000,
- "profit": 10228356,
+ "profit": 6273695,
"yearly": {
- "2021": 0.84,
+ "2021": 0.82,
"2022": -10.62,
- "2023": 5.87,
- "2024": -3.2,
- "2025": 43.9,
- "2026": 52.22
+ "2023": 7.54,
+ "2024": -1.58,
+ "2025": 36.75,
+ "2026": 24.78
}
},
"trend50_dd10": {
"label": "추세 절반 + 낙폭 제어",
"years": 4.79,
- "cagr_pct": 15.5,
- "vol_pct": 15.16,
- "sharpe": 0.86,
- "mdd_pct": -16.41,
- "calmar": 0.95,
+ "cagr_pct": 9.9,
+ "period_return_pct": 57.22,
+ "vol_pct": 12.84,
+ "sharpe": 0.59,
+ "mdd_pct": -13.04,
+ "calmar": 0.76,
"worst_year_pct": -10.02,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 36.8,
- "avg_exposure_pct": 37.7,
- "turnover_per_year_pct": 99.5,
- "final_value": 19951442,
+ "negative_months_pct": 37.9,
+ "avg_exposure_pct": 37.5,
+ "turnover_per_year_pct": 95.1,
+ "final_value": 15721932,
"contributed": 10000000,
- "profit": 9951442,
+ "profit": 5721932,
"yearly": {
- "2021": 0.84,
+ "2021": 0.82,
"2022": -10.02,
- "2023": 7.19,
- "2024": -5.03,
- "2025": 41.68,
- "2026": 52.49
+ "2023": 6.86,
+ "2024": -4.26,
+ "2025": 35.85,
+ "2026": 24.71
+ }
+ },
+ "trend50_dd10_min": {
+ "label": "추세·낙폭 중 더 낮은 비중",
+ "years": 4.79,
+ "cagr_pct": 10.97,
+ "period_return_pct": 64.69,
+ "vol_pct": 13.49,
+ "sharpe": 0.64,
+ "mdd_pct": -13.84,
+ "calmar": 0.79,
+ "worst_year_pct": -10.62,
+ "losing_years": 2,
+ "total_years": 6,
+ "negative_months_pct": 39.7,
+ "avg_exposure_pct": 43.5,
+ "turnover_per_year_pct": 74.1,
+ "final_value": 16469075,
+ "contributed": 10000000,
+ "profit": 6469075,
+ "yearly": {
+ "2021": 0.82,
+ "2022": -10.62,
+ "2023": 7.82,
+ "2024": -0.66,
+ "2025": 36.75,
+ "2026": 24.78
}
},
"trend50_vol20": {
"label": "추세 절반 + 변동성 20%",
"years": 4.79,
- "cagr_pct": 10.59,
- "vol_pct": 12.88,
- "sharpe": 0.64,
- "mdd_pct": -16.06,
- "calmar": 0.66,
- "worst_year_pct": -12.87,
+ "cagr_pct": 7.48,
+ "period_return_pct": 41.31,
+ "vol_pct": 11.76,
+ "sharpe": 0.44,
+ "mdd_pct": -16.07,
+ "calmar": 0.47,
+ "worst_year_pct": -12.88,
"losing_years": 2,
"total_years": 6,
- "negative_months_pct": 40.4,
- "avg_exposure_pct": 44.0,
- "turnover_per_year_pct": 138.9,
- "final_value": 16195395,
+ "negative_months_pct": 44.8,
+ "avg_exposure_pct": 44.3,
+ "turnover_per_year_pct": 141.6,
+ "final_value": 14131061,
"contributed": 10000000,
- "profit": 6195395,
+ "profit": 4131061,
"yearly": {
- "2021": 0.84,
- "2022": -12.87,
- "2023": 10.76,
- "2024": -5.67,
- "2025": 37.68,
- "2026": 28.17
+ "2021": 0.82,
+ "2022": -12.88,
+ "2023": 10.03,
+ "2024": -4.8,
+ "2025": 32.25,
+ "2026": 16.14
}
}
}
+ },
+ "exposure_review": {
+ "start": "2021-12-01",
+ "symbols": [
+ "005930",
+ "035420",
+ "005380",
+ "051910",
+ "005490",
+ "055550",
+ "035720",
+ "012330",
+ "105560"
+ ],
+ "fractions": {
+ "60%": {
+ "label": "고정 비중",
+ "years": 4.79,
+ "cagr_pct": 6.07,
+ "period_return_pct": 32.64,
+ "vol_pct": 16.47,
+ "sharpe": 0.45,
+ "mdd_pct": -19.53,
+ "calmar": 0.31,
+ "worst_year_pct": -13.42,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 59.4,
+ "turnover_per_year_pct": 14.5,
+ "final_value": 13263508,
+ "contributed": 10000000,
+ "profit": 3263508,
+ "yearly": {
+ "2021": -0.04,
+ "2022": -13.42,
+ "2023": 13.72,
+ "2024": -4.68,
+ "2025": 28.48,
+ "2026": 10.05
+ },
+ "up_capture": 0.4533433077226763,
+ "down_capture": 0.48351184271431236,
+ "up_days": 633,
+ "down_days": 537
+ },
+ "70%": {
+ "label": "고정 비중",
+ "years": 4.79,
+ "cagr_pct": 7.02,
+ "period_return_pct": 38.4,
+ "vol_pct": 19.1,
+ "sharpe": 0.46,
+ "mdd_pct": -22.01,
+ "calmar": 0.32,
+ "worst_year_pct": -15.66,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 69.4,
+ "turnover_per_year_pct": 16.2,
+ "final_value": 13839548,
+ "contributed": 10000000,
+ "profit": 3839548,
+ "yearly": {
+ "2021": -0.05,
+ "2022": -15.66,
+ "2023": 16.44,
+ "2024": -5.48,
+ "2025": 33.59,
+ "2026": 11.66
+ },
+ "up_capture": 0.52865273876406,
+ "down_capture": 0.5638862401315071,
+ "up_days": 633,
+ "down_days": 537
+ },
+ "80%": {
+ "label": "고정 비중",
+ "years": 4.79,
+ "cagr_pct": 8.28,
+ "period_return_pct": 46.41,
+ "vol_pct": 21.48,
+ "sharpe": 0.49,
+ "mdd_pct": -23.63,
+ "calmar": 0.35,
+ "worst_year_pct": -17.9,
+ "losing_years": 3,
+ "total_years": 6,
+ "negative_months_pct": 46.6,
+ "avg_exposure_pct": 79.3,
+ "turnover_per_year_pct": 18.6,
+ "final_value": 14641114,
+ "contributed": 10000000,
+ "profit": 4641114,
+ "yearly": {
+ "2021": -0.06,
+ "2022": -17.9,
+ "2023": 19.3,
+ "2024": -6.28,
+ "2025": 38.82,
+ "2026": 14.95
+ },
+ "up_capture": 0.6019123990065971,
+ "down_capture": 0.639747335367282,
+ "up_days": 633,
+ "down_days": 537
+ }
+ }
}
}
\ No newline at end of file
diff --git a/reports/research/risk_overlay_backtest.md b/reports/research/risk_overlay_backtest.md
index c1601664..3d3aa385 100644
--- a/reports/research/risk_overlay_backtest.md
+++ b/reports/research/risk_overlay_backtest.md
@@ -6,41 +6,68 @@
| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
-| 고정 비중(현행) | +7.45% | -28.8% | 0.42 | 0.26 | -18.7% | 7/25 | 51% | 9% |
+| 고정 비중 | +7.45% | -28.8% | 0.42 | 0.26 | -18.7% | 7/25 | 51% | 9% |
| 추세 필터 · 200일선 아래면 절반 | +7.19% | -21.8% | 0.45 | 0.33 | -12.3% | 7/25 | 41% | 54% |
| 추세 필터 · 200일선 아래면 0 | +6.79% | -21.8% | 0.44 | 0.31 | -6.8% | 10/25 | 31% | 102% |
| 변동성 목표 15% | +5.79% | -17.8% | 0.38 | 0.33 | -12.3% | 6/25 | 41% | 58% |
| 변동성 목표 20% | +6.32% | -20.9% | 0.39 | 0.3 | -14.7% | 6/25 | 46% | 30% |
-| 낙폭 제어 · -10%에서 절반 | +6.91% | -23.8% | 0.41 | 0.29 | -14.6% | 7/25 | 48% | 32% |
-| 추세 절반 + 낙폭 제어 | +6.66% | -20.3% | 0.43 | 0.33 | -13.1% | 7/25 | 41% | 68% |
+| 낙폭 제어 · -10%에서 절반 | +6.06% | -19.4% | 0.37 | 0.31 | -12.4% | 9/25 | 39% | 38% |
+| 추세 절반 + 낙폭 제어 | +6.58% | -16.5% | 0.45 | 0.4 | -8.7% | 8/25 | 36% | 62% |
+| 추세·낙폭 중 더 낮은 비중 | +6.72% | -19.2% | 0.44 | 0.35 | -12.3% | 7/25 | 38% | 60% |
| 추세 절반 + 변동성 20% | +6.22% | -13.8% | 0.44 | 0.45 | -8.9% | 7/25 | 37% | 69% |
### 적립 트랙 (KODEX 200 ETF, 2014~)
-- 데이터: 069500 2014-06-30 → 2026-09-17
+- 데이터: 069500 2014-06-30 → 2026-09-16
| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
-| 고정 비중(현행) | +9.62% | -21.7% | 0.59 | 0.44 | -11.2% | 4/13 | 51% | 17% |
-| 추세 필터 · 200일선 아래면 절반 | +9.32% | -21.7% | 0.62 | 0.43 | -4.6% | 4/13 | 40% | 57% |
-| 추세 필터 · 200일선 아래면 0 | +8.86% | -21.7% | 0.60 | 0.41 | -6.1% | 3/13 | 28% | 101% |
-| 변동성 목표 15% | +6.59% | -15.7% | 0.48 | 0.42 | -10.0% | 4/13 | 44% | 69% |
-| 변동성 목표 20% | +7.44% | -17.0% | 0.52 | 0.44 | -11.0% | 4/13 | 48% | 36% |
-| 낙폭 제어 · -10%에서 절반 | +8.84% | -17.3% | 0.59 | 0.51 | -11.2% | 4/13 | 50% | 43% |
-| 추세 절반 + 낙폭 제어 | +8.76% | -16.3% | 0.64 | 0.54 | -4.6% | 4/13 | 39% | 83% |
-| 추세 절반 + 변동성 20% | +7.45% | -11.9% | 0.61 | 0.63 | -4.3% | 4/13 | 36% | 69% |
+| 고정 비중 | +9.59% | -21.7% | 0.59 | 0.44 | -11.2% | 4/13 | 51% | 17% |
+| 추세 필터 · 200일선 아래면 절반 | +9.29% | -21.7% | 0.62 | 0.43 | -4.6% | 4/13 | 39% | 58% |
+| 추세 필터 · 200일선 아래면 0 | +8.84% | -21.7% | 0.60 | 0.41 | -6.1% | 3/13 | 28% | 101% |
+| 변동성 목표 15% | +6.58% | -15.7% | 0.47 | 0.42 | -10.0% | 4/13 | 44% | 69% |
+| 변동성 목표 20% | +7.43% | -17.0% | 0.52 | 0.44 | -11.0% | 4/13 | 48% | 36% |
+| 낙폭 제어 · -10%에서 절반 | +8.08% | -16.3% | 0.56 | 0.5 | -7.5% | 4/13 | 43% | 55% |
+| 추세 절반 + 낙폭 제어 | +8.65% | -16.4% | 0.64 | 0.53 | -4.6% | 4/13 | 38% | 72% |
+| 추세·낙폭 중 더 낮은 비중 | +9.08% | -16.4% | 0.68 | 0.55 | -4.6% | 4/13 | 39% | 71% |
+| 추세 절반 + 변동성 20% | +7.43% | -11.9% | 0.61 | 0.62 | -4.3% | 4/13 | 36% | 69% |
-### 관찰 트랙 (대형주 10종목, 2021-12~)
+### 관찰 트랙 (지금 들고 있는 9종목, 이자 없는 현금, 2021-12~)
-- 데이터: EW10 2021-12-01 → 2026-09-17
+- 데이터: EW9 2021-12-01 → 2026-09-16
| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
-| 고정 비중(현행) | +18.32% | -26.1% | 0.82 | 0.7 | -13.5% | 2/6 | 61% | 22% |
-| 추세 필터 · 200일선 아래면 절반 | +16.36% | -24.9% | 0.76 | 0.66 | -12.6% | 2/6 | 53% | 86% |
-| 추세 필터 · 200일선 아래면 0 | +13.92% | -24.9% | 0.65 | 0.56 | -11.3% | 2/6 | 45% | 150% |
-| 변동성 목표 15% | +9.70% | -15.1% | 0.61 | 0.64 | -11.8% | 2/6 | 41% | 97% |
-| 변동성 목표 20% | +12.40% | -18.0% | 0.71 | 0.69 | -14.2% | 2/6 | 51% | 100% |
-| 낙폭 제어 · -10%에서 절반 | +15.84% | -16.4% | 0.85 | 0.97 | -10.6% | 2/6 | 43% | 78% |
-| 추세 절반 + 낙폭 제어 | +15.50% | -16.4% | 0.86 | 0.95 | -10.0% | 2/6 | 38% | 100% |
-| 추세 절반 + 변동성 20% | +10.59% | -16.1% | 0.64 | 0.66 | -12.9% | 2/6 | 44% | 139% |
+| 고정 비중 | +6.07% | -19.5% | 0.45 | 0.31 | -13.4% | 3/6 | 59% | 14% |
+| 추세 필터 · 200일선 아래면 절반 | +4.58% | -18.1% | 0.39 | 0.25 | -12.6% | 3/6 | 52% | 82% |
+| 추세 필터 · 200일선 아래면 0 | +2.44% | -24.9% | 0.25 | 0.1 | -11.6% | 4/6 | 44% | 153% |
+| 변동성 목표 15% | +2.62% | -15.8% | 0.30 | 0.17 | -12.1% | 3/6 | 42% | 108% |
+| 변동성 목표 20% | +3.87% | -18.5% | 0.37 | 0.21 | -14.1% | 3/6 | 52% | 93% |
+| 낙폭 제어 · -10%에서 절반 | +3.10% | -15.0% | 0.32 | 0.21 | -11.1% | 3/6 | 39% | 58% |
+| 추세 절반 + 낙폭 제어 | +2.68% | -15.0% | 0.30 | 0.18 | -10.6% | 3/6 | 33% | 80% |
+| 추세·낙폭 중 더 낮은 비중 | +3.10% | -15.0% | 0.32 | 0.21 | -11.1% | 3/6 | 39% | 58% |
+| 추세 절반 + 변동성 20% | +2.20% | -18.9% | 0.25 | 0.12 | -13.1% | 3/6 | 45% | 141% |
+
+### 참고 · 예전 표 조건 (000660 포함 10종목, 현금 연 3%)
+
+- 데이터: EW10 2021-12-01 → 2026-09-16
+
+| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |
+|---|---:|---:|---:|---:|---:|---:|---:|---:|
+| 고정 비중 | +13.59% | -17.4% | 0.68 | 0.78 | -13.5% | 2/6 | 61% | 19% |
+| 추세 필터 · 200일선 아래면 절반 | +11.32% | -15.8% | 0.59 | 0.72 | -12.6% | 2/6 | 52% | 81% |
+| 추세 필터 · 200일선 아래면 0 | +8.90% | -20.1% | 0.46 | 0.44 | -11.3% | 2/6 | 44% | 149% |
+| 변동성 목표 15% | +7.78% | -15.1% | 0.49 | 0.51 | -11.8% | 2/6 | 42% | 98% |
+| 변동성 목표 20% | +9.45% | -18.0% | 0.55 | 0.53 | -14.2% | 2/6 | 52% | 101% |
+| 낙폭 제어 · -10%에서 절반 | +10.70% | -13.8% | 0.62 | 0.77 | -10.6% | 2/6 | 44% | 74% |
+| 추세 절반 + 낙폭 제어 | +9.90% | -13.0% | 0.59 | 0.76 | -10.0% | 2/6 | 38% | 95% |
+| 추세·낙폭 중 더 낮은 비중 | +10.97% | -13.8% | 0.64 | 0.79 | -10.6% | 2/6 | 44% | 74% |
+| 추세 절반 + 변동성 20% | +7.48% | -16.1% | 0.44 | 0.47 | -12.9% | 2/6 | 44% | 142% |
+
+### 주식 비중별 비교 (지금 들고 있는 종목, 고정 비중, 이자 없는 현금, 2021-12~)
+
+| 주식 비중 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 상승 포착 | 하락 포착 |
+|---|---:|---:|---:|---:|---:|
+| 60% | +6.07% | -19.5% | 0.45 | 45% | 48% |
+| 70% | +7.02% | -22.0% | 0.46 | 53% | 56% |
+| 80% | +8.28% | -23.6% | 0.49 | 60% | 64% |
diff --git a/strategies/breakout_volume.py b/strategies/breakout_volume.py
index d1bb2705..91124a55 100644
--- a/strategies/breakout_volume.py
+++ b/strategies/breakout_volume.py
@@ -12,11 +12,20 @@
→ 전일 조건 미충족, 당일 충족 시에만 BUY (edge-trigger)
Exit:
- 전략 레벨 최소 실패 신호만 사용: close < breakout_ref
+ 전략 레벨 최소 실패 신호만 사용: close < entry_level
+ entry_level = 가장 최근 BUY 봉에서 돌파한 breakout_ref (다음 BUY까지 고정).
+ 종가가 이 레벨 아래에 있는 봉마다 SELL (level-trigger). 최소 보유일 규칙에 막힌
+ SELL도 다음 봉에서 다시 나오므로 청산이 사라지지 않는다. 보유하지 않은 종목의
+ SELL은 백테스터가 무시한다.
나머지 손절/트레일링은 기존 backtester risk layer (ATR 2.5) 위임.
+ (예전에는 매 봉의 breakout_ref와 비교했는데, T+1의 breakout_ref는 돌파봉 T의
+ 고가까지 포함하므로 "돌파 레벨 재이탈"이 아니라 "close[T+1] < high[T]"를
+ 검사하게 되어 진입 다음 봉에 SELL이 나는 1일 왕복 전략이 됐다.)
+
Look-ahead 방지:
breakout_ref, avg_vol_ref 모두 .shift(1) 적용 → 현재 봉의 high/volume 제외.
+ entry_level은 과거 BUY 봉의 breakout_ref를 앞으로만 채운다(ffill).
"""
import pandas as pd
@@ -79,10 +88,14 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
entry_prev = entry_cond.shift(1, fill_value=False)
entry_edge = entry_cond & (~entry_prev)
- # ── Exit: 최소 실패 신호 (close < breakout_ref) ──
- exit_cond = breakout_ref.notna() & (close < breakout_ref)
- exit_prev = exit_cond.shift(1, fill_value=False)
- exit_edge = exit_cond & (~exit_prev)
+ # ── Exit: 최소 실패 신호 (close < 진입 때 돌파한 레벨) ──
+ # 매 봉의 breakout_ref는 돌파봉 자신의 고가까지 올라가므로 청산 기준으로 쓰면
+ # 진입 다음 봉에 대부분 SELL이 났다. 진입 봉의 breakout_ref를 다음 진입까지 고정한다.
+ entry_level = breakout_ref.where(entry_edge).ffill()
+ exit_cond = entry_level.notna() & (close < entry_level)
+ # level-trigger: 레벨 아래에 있는 동안 매 봉 SELL. 최소 보유일에 막힌 청산이
+ # 다음 봉에서 다시 나오게 한다 (edge-trigger면 한 번 막히면 영영 사라진다).
+ exit_trigger = exit_cond
# ── 디버그 컬럼 ──
volume_surge_ratio = pd.Series(np.nan, index=analyzed.index)
@@ -90,12 +103,13 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
volume_surge_ratio[valid_vol] = volume[valid_vol] / avg_vol_ref[valid_vol]
analyzed["breakout_ref"] = breakout_ref
+ analyzed["entry_level"] = entry_level
analyzed["avg_vol_ref"] = avg_vol_ref
analyzed["volume_surge_ratio"] = volume_surge_ratio
analyzed["entry_condition"] = entry_cond
analyzed["exit_condition"] = exit_cond
analyzed["entry_trigger"] = entry_edge
- analyzed["exit_trigger"] = exit_edge
+ analyzed["exit_trigger"] = exit_trigger
# ── strategy_score: 연속 ranking 점수 (동시 BUY 후보 정렬용) ──
# 돌파 강도 + 거래량 급증도 + 추세 강도의 도메인 고정 스케일링 가중합.
@@ -134,7 +148,7 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
# ── signal 생성 ──
analyzed["signal"] = self.HOLD
analyzed.loc[entry_edge.fillna(False), "signal"] = self.BUY
- analyzed.loc[exit_edge.fillna(False), "signal"] = self.SELL
+ analyzed.loc[exit_trigger.fillna(False), "signal"] = self.SELL
analyzed.loc[analyzed["signal"] == self.SELL, "total_score"] *= -1
analyzed.loc[analyzed["signal"] == self.SELL, "strategy_score"] *= -1
@@ -162,6 +176,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
"details": {
"ADX": round(last.get("adx", 0), 2) if pd.notna(last.get("adx")) else 0,
"breakout_ref": round(last.get("breakout_ref", 0), 0) if pd.notna(last.get("breakout_ref")) else 0,
+ "entry_level": round(last.get("entry_level", 0), 0) if pd.notna(last.get("entry_level")) else 0,
"avg_vol_ref": round(last.get("avg_vol_ref", 0), 0) if pd.notna(last.get("avg_vol_ref")) else 0,
"volume_surge_ratio": round(last.get("volume_surge_ratio", 0), 2) if pd.notna(last.get("volume_surge_ratio")) else 0,
"close": last.get("close", 0),
diff --git a/strategies/index_cache.py b/strategies/index_cache.py
new file mode 100644
index 00000000..ae7efe06
--- /dev/null
+++ b/strategies/index_cache.py
@@ -0,0 +1,112 @@
+"""
+지수(벤치마크) 종가 캐시 — 전략 인스턴스당 넓은 구간을 한 번 받아 두고 잘라 쓴다.
+
+strict 백테스트는 analyze()를 봉마다 df.iloc[:i+1]로 부른다. 예전 캐시는 호출의
+(시작, 끝) 날짜를 키로 써서 끝 날짜가 바뀌는 매 봉마다 지수를 새로 내려받았고
+(호출마다 새 DataCollector라 수집기 캐시도 못 탔다), 시장 필터는 반대로 첫 호출
+구간(strict에서는 첫 1일)으로 굳어 버렸다.
+
+규칙:
+ - 첫 요청 때 [요청 시작 - warmup_days, max(오늘, 요청 끝)] 구간을 한 번 받는다.
+ - 이후 요청이 받아 둔 구간 안이면 네트워크 없이 캐시를 쓴다.
+ - 요청 시작이 더 이르거나(다른 종목) 요청 끝이 받아 둔 끝보다 늦으면(날짜가 넘어간
+ 장기 실행 프로세스) 합친 구간으로 다시 받는다.
+ - 호출하는 쪽은 파생 시리즈(SMA·수익률 등 과거만 보는 계산)를 전체 캐시로 한 번
+ 계산해 두고(version이 바뀔 때만 재계산), 매 호출마다 요청 끝 날짜 이하로 잘라 쓴다.
+ 그래서 요청 시점 이후 봉은 결과에 섞이지 않는다.
+ - 조회 실패도 같은 구간 동안 기억해 봉마다 재시도하지 않는다. 사유는 last_error.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+import pandas as pd
+from loguru import logger
+
+
+class IndexCloseCache:
+ """한 지수의 일봉 종가를 넓게 캐시하고 요청 끝 날짜 이하로 잘라 준다."""
+
+ def __init__(self, symbol: str, warmup_days: int, *, label: str = ""):
+ self.symbol = str(symbol)
+ self.warmup_days = max(0, int(warmup_days))
+ self.label = label or self.symbol
+ self.fetch_count = 0 # 실제 수집 시도 횟수 (테스트·진단용)
+ self.version = 0 # 수집할 때마다 증가 — 파생 시리즈 재계산 판단용
+ self.last_error: str | None = None
+ self._closes: pd.Series | None = None
+ self._span: tuple[pd.Timestamp, pd.Timestamp] | None = None
+ self._collector = None
+
+ def ensure(self, first, last) -> pd.Series | None:
+ """[first - warmup, last]를 덮도록 캐시를 채우고 캐시된 종가 전체를 반환.
+
+ first는 계산이 필요한 가장 이른 날짜, last는 요청 끝 날짜다. 반환 시리즈는
+ last 이후 봉을 포함할 수 있으므로 호출하는 쪽이 last 이하로 잘라 써야 한다.
+ 받을 수 없으면 None (사유는 last_error).
+ """
+ first = pd.Timestamp(first).normalize()
+ last = pd.Timestamp(last).normalize()
+ if not self._covers(first, last):
+ self._fetch(first, last)
+ return self._closes
+
+ def _covers(self, first: pd.Timestamp, last: pd.Timestamp) -> bool:
+ if self._span is None:
+ return False
+ span_first, span_last = self._span
+ return span_first <= first and last <= span_last
+
+ def _get_collector(self):
+ if self._collector is None:
+ from core.data_collector import DataCollector
+
+ self._collector = DataCollector()
+ self._collector.quiet_ohlcv_log = True
+ return self._collector
+
+ def _fetch(self, first: pd.Timestamp, last: pd.Timestamp) -> None:
+ if self._span is not None:
+ first = min(first, self._span[0])
+ last = max(last, self._span[1])
+ today = pd.Timestamp(datetime.now().date())
+ end = max(last, today)
+ start = first - pd.Timedelta(days=self.warmup_days)
+ # 실패해도 같은 구간은 다시 시도하지 않도록 구간을 먼저 기록한다.
+ self._span = (first, end)
+ self.fetch_count += 1
+ self.version += 1
+ try:
+ df = self._get_collector().fetch_korean_stock(
+ self.symbol,
+ start_date=start.strftime("%Y-%m-%d"),
+ end_date=end.strftime("%Y-%m-%d"),
+ )
+ except Exception as e: # 수집기 예외 종류가 소스별로 달라 넓게 받되, 사유를 남긴다
+ self._closes = None
+ self.last_error = f"{type(e).__name__}: {e}"
+ logger.warning(
+ "{}: 지수 {} 조회 실패 ({} ~ {}) — {}",
+ self.label, self.symbol, start.date(), end.date(), self.last_error,
+ )
+ return
+
+ if df is None or df.empty or "close" not in df.columns:
+ self._closes = None
+ self.last_error = "empty"
+ logger.warning(
+ "{}: 지수 {} 데이터 없음 ({} ~ {})",
+ self.label, self.symbol, start.date(), end.date(),
+ )
+ return
+
+ if "date" in df.columns:
+ df = df.set_index("date")
+ closes = df["close"].astype(float)
+ closes.index = pd.to_datetime(closes.index)
+ if closes.index.tz is not None:
+ closes.index = closes.index.tz_localize(None)
+ closes = closes[~closes.index.duplicated(keep="last")].sort_index()
+ self._closes = closes
+ self.last_error = None
diff --git a/strategies/mean_reversion.py b/strategies/mean_reversion.py
index 98ab757a..6479e93b 100644
--- a/strategies/mean_reversion.py
+++ b/strategies/mean_reversion.py
@@ -45,8 +45,38 @@ def __init__(self, config: Config = None):
self.params = self.config.strategies.get("mean_reversion", {})
logger.info("MeanReversionStrategy 초기화 완료")
+ def unmodelled_backtest_filters(self) -> list[str]:
+ """설정에서 켜져 있지만 analyze()(백테스트 경로)가 반영하지 않는 필터 이름.
+
+ pykrx·yfinance는 '현재' 재무와 '현재' 코스피200 구성만 주므로, 과거 봉에
+ 적용하면 미래 정보와 생존 편향이 섞인다. 이 필터들은 generate_signal()
+ (paper 판단)에서만 적용된다.
+ """
+ enabled = []
+ if self.params.get("restrict_to_kospi200", False):
+ enabled.append("restrict_to_kospi200")
+ if (self.params.get("fundamental_filter") or {}).get("enabled", False):
+ enabled.append("fundamental_filter")
+ return enabled
+
def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
- """지표 계산 + Z-Score + 전략 signal 컬럼 추가"""
+ """지표 계산 + Z-Score + 전략 signal 컬럼 추가.
+
+ 52주 고점 급락·52주 저점 근방 매수 제외는 여기서 벡터로 적용한다(롤링 창이라
+ 미래 정보 없음). 백테스트와 paper가 같은 매수 규칙을 보게 하려는 것이다.
+ 펀더멘털 필터와 코스피200 제한은 과거 시점 데이터가 없어 반영하지 않는다
+ (unmodelled_backtest_filters 참고).
+ """
+ unmodelled = self.unmodelled_backtest_filters()
+ if unmodelled and not getattr(self, "_unmodelled_warned", False):
+ logger.warning(
+ "평균회귀 analyze(백테스트 경로)는 {} 필터를 반영하지 않습니다 — "
+ "과거 시점 데이터가 없어 적용하면 미래 정보가 섞입니다. "
+ "백테스트 결과는 이 필터가 없는 전략의 성과입니다.",
+ ", ".join(unmodelled),
+ )
+ self._unmodelled_warned = True
+
analyzed = self.indicator_engine.calculate_all(df.copy())
if analyzed.empty:
return analyzed
@@ -87,6 +117,23 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
# 거래량 급변 시 평균회귀 매수는 차단
buy_signal = buy_signal & ~((volume_ratio > volume_spike_filter).fillna(False))
+ # 52주 필터 (예전에는 generate_signal에서만 적용돼 백테스트가 paper와 다른
+ # 전략을 평가했다 — 백테스트 BUY의 약 절반이 paper에서는 걸러지는 매수였다)
+ # - 52주 고점 대비 max_drawdown_from_52w_high 이상 하락 → 매수 제외
+ # - 52주 저점 대비 near_52w_low_pct 이내 → 신저가 근방이라 매수 제외
+ exclude_52w = self.params.get("exclude_52w_low_near", True)
+ max_drawdown_52w = self.params.get("max_drawdown_from_52w_high", 0.30)
+ near_low_threshold = self.params.get("near_52w_low_pct", 0.05)
+ veto_drawdown = pd.Series(False, index=analyzed.index)
+ veto_near_low = pd.Series(False, index=analyzed.index)
+ if exclude_52w:
+ raw_buy = buy_signal.fillna(False).astype(bool)
+ veto_drawdown = raw_buy & (analyzed["drawdown_from_52w_high"] >= max_drawdown_52w)
+ veto_near_low = raw_buy & (analyzed["pct_above_52w_low"] <= near_low_threshold)
+ buy_signal = raw_buy & ~veto_drawdown & ~veto_near_low
+ analyzed["buy_veto_52w_drawdown"] = veto_drawdown
+ analyzed["buy_veto_52w_near_low"] = veto_near_low
+
analyzed["signal"] = self.HOLD
analyzed.loc[buy_signal.fillna(False), "signal"] = self.BUY
analyzed.loc[sell_signal.fillna(False), "signal"] = self.SELL
@@ -136,30 +183,26 @@ def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dic
drawdown_52w = last.get("drawdown_from_52w_high")
pct_above_52w = last.get("pct_above_52w_low")
+ # 52주 필터는 analyze()에서 이미 적용됐다(백테스트와 같은 규칙). 여기서는 사유만 남긴다.
# 52주 고점 대비 하락률 필터: 고점에서 N% 이상 하락한 종목은 실적 악화·장기 하락 가능성 → 매수 제외
- exclude_52w = self.params.get("exclude_52w_low_near", True)
max_drawdown_52w = self.params.get("max_drawdown_from_52w_high", 0.30)
- if signal == self.BUY and exclude_52w and drawdown_52w is not None:
- if drawdown_52w >= max_drawdown_52w:
- signal = self.HOLD
- logger.info(
- "평균회귀 매수 보류(52주 고점 대비 급락): {} — 52주고점 대비 -{:.1f}% (한도 -{:.0f}%)",
- symbol or "?",
- float(drawdown_52w) * 100,
- max_drawdown_52w * 100,
- )
+ if bool(last.get("buy_veto_52w_drawdown", False)):
+ logger.info(
+ "평균회귀 매수 보류(52주 고점 대비 급락): {} — 52주고점 대비 -{:.1f}% (한도 -{:.0f}%)",
+ symbol or "?",
+ float(drawdown_52w) * 100,
+ max_drawdown_52w * 100,
+ )
# 52주 저점 근방 필터: 현재가가 저점 대비 N% 이내이면 신저가 구간 → 매수 제외
near_low_threshold = self.params.get("near_52w_low_pct", 0.05)
- if signal == self.BUY and exclude_52w and pct_above_52w is not None:
- if pct_above_52w <= near_low_threshold:
- signal = self.HOLD
- logger.info(
- "평균회귀 매수 보류(52주 신저가 근방): {} — 52주저점 대비 +{:.1f}% (한도 +{:.0f}%)",
- symbol or "?",
- float(pct_above_52w) * 100,
- near_low_threshold * 100,
- )
+ if bool(last.get("buy_veto_52w_near_low", False)):
+ logger.info(
+ "평균회귀 매수 보류(52주 신저가 근방): {} — 52주저점 대비 +{:.1f}% (한도 +{:.0f}%)",
+ symbol or "?",
+ float(pct_above_52w) * 100,
+ near_low_threshold * 100,
+ )
details = {
"Z-Score": round(z_score, 2),
diff --git a/strategies/momentum_factor.py b/strategies/momentum_factor.py
index 67a70012..935facd9 100644
--- a/strategies/momentum_factor.py
+++ b/strategies/momentum_factor.py
@@ -6,9 +6,9 @@
import pandas as pd
import numpy as np
-from loguru import logger
from strategies.base_strategy import BaseStrategy
+from strategies.index_cache import IndexCloseCache
from config.config_loader import Config
@@ -32,7 +32,10 @@ def __init__(self, config: Config = None):
)
self.config = config or Config.get()
self.params = self.config.strategies.get("momentum_factor", {})
- self._benchmark_return_cache: dict[tuple[str, int, str, str], pd.Series] = {}
+ # 지수 종가 캐시: (지수, 워밍업 일수) → IndexCloseCache
+ self._benchmark_index_caches: dict[tuple[str, int], IndexCloseCache] = {}
+ # 파생 수익률 캐시: (지수, lookback) → (캐시 version, 수익률 시리즈)
+ self._benchmark_return_cache: dict[tuple[str, int], tuple[int, pd.Series]] = {}
def _benchmark_return(
self,
@@ -40,46 +43,41 @@ def _benchmark_return(
lookback: int,
benchmark_symbol: str,
) -> pd.Series:
- """Return benchmark N-day momentum aligned to the input index."""
+ """Return benchmark N-day momentum aligned to the input index.
+
+ 예전에는 (시작, 끝) 날짜를 캐시 키로 써서 strict 백테스트(봉마다 df.iloc[:i+1])가
+ 봉마다 지수를 새로 받았다(호출마다 새 DataCollector). 이제 지수를 인스턴스당
+ 넓게 한 번 받아 수익률을 한 번 계산하고, 호출의 끝 날짜 이하로 잘라 맞춘다.
+ 조회 실패는 IndexCloseCache가 사유와 함께 경고하고, 여기서는 NaN(매수 없음)을 준다.
+ """
if len(index) == 0:
return pd.Series(dtype=float, index=index)
- try:
- from core.data_collector import DataCollector
-
- dates = pd.to_datetime(index)
- margin_days = max(lookback * 3, 120)
- start = (dates.min() - pd.Timedelta(days=margin_days)).strftime("%Y-%m-%d")
- end = dates.max().strftime("%Y-%m-%d")
- cache_key = (benchmark_symbol, lookback, start, end)
- if cache_key in self._benchmark_return_cache:
- benchmark_return = self._benchmark_return_cache[cache_key]
- aligned = benchmark_return.reindex(dates, method="ffill")
- return pd.Series(aligned.to_numpy(), index=index)
-
- collector = DataCollector()
- collector.quiet_ohlcv_log = True
- benchmark = collector.fetch_korean_stock(
- benchmark_symbol,
- start_date=start,
- end_date=end,
+ lookback = int(lookback)
+ dates = pd.to_datetime(index)
+ margin_days = max(lookback * 3, 120)
+ cache_key = (benchmark_symbol, margin_days)
+ cache = self._benchmark_index_caches.get(cache_key)
+ if cache is None:
+ cache = IndexCloseCache(
+ benchmark_symbol, warmup_days=margin_days, label="benchmark-relative momentum",
)
- if benchmark is None or benchmark.empty:
- logger.warning("benchmark-relative momentum: benchmark data unavailable")
- return pd.Series(np.nan, index=index)
-
- if "date" in benchmark.columns:
- benchmark = benchmark.set_index("date")
- benchmark.index = pd.to_datetime(benchmark.index)
- close = benchmark["close"].astype(float)
- benchmark_return = (close / close.shift(lookback) - 1) * 100
- self._benchmark_return_cache[cache_key] = benchmark_return
- aligned = benchmark_return.reindex(dates, method="ffill")
- return pd.Series(aligned.to_numpy(), index=index)
- except Exception as e:
- logger.warning("benchmark-relative momentum disabled: {}", e)
+ self._benchmark_index_caches[cache_key] = cache
+
+ closes = cache.ensure(dates.min(), dates.max())
+ if closes is None or closes.empty:
return pd.Series(np.nan, index=index)
+ ret_key = (benchmark_symbol, lookback)
+ cached = self._benchmark_return_cache.get(ret_key)
+ if cached is None or cached[0] != cache.version:
+ benchmark_return = (closes / closes.shift(lookback) - 1) * 100
+ cached = (cache.version, benchmark_return)
+ self._benchmark_return_cache[ret_key] = cached
+ # 호출 끝 날짜 이후 봉은 잘라 낸다 (strict 백테스트에서 벤치마크 미래 정보 차단)
+ aligned = cached[1].loc[: dates.max()].reindex(dates, method="ffill")
+ return pd.Series(aligned.to_numpy(), index=index)
+
def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
"""lookback 일 수익률 계산 후 신호 부여"""
result = df.copy()
diff --git a/strategies/relative_strength_rotation.py b/strategies/relative_strength_rotation.py
index 782be650..c5602a1f 100644
--- a/strategies/relative_strength_rotation.py
+++ b/strategies/relative_strength_rotation.py
@@ -27,6 +27,7 @@
from loguru import logger
from strategies.base_strategy import BaseStrategy
+from strategies.index_cache import IndexCloseCache
from core.indicator_engine import IndicatorEngine
from config.config_loader import Config
@@ -46,40 +47,65 @@ def __init__(self, config: Config = None):
self.config = config or Config.get()
self.indicator_engine = IndicatorEngine(self.config)
self.params = self.config.strategies.get("relative_strength_rotation", {})
- self._mf_series = None # KS11 > SMA200 market filter cache
- self._benchmark_composite_cache = {}
+ # KS11 > SMA 시장 필터: 날짜별 통과 여부(T-1 기준). NA = 판단 불가(데이터·SMA 없음)
+ self._mf_series = None
+ self._mf_cache: IndexCloseCache | None = None
+ self._mf_cache_period = None
+ self._mf_series_key = None
+ # 시장 필터 상태 — 조회 실패 시 ok=False. 연구 리포트가 '필터 미적용' 실행을 걸러낼 수 있게 남긴다.
+ self.market_filter_status: dict = {"ok": None, "reason": None}
+ # 벤치마크 지수 캐시: (지수, 워밍업 일수) → IndexCloseCache
+ self._benchmark_index_caches: dict[tuple[str, int], IndexCloseCache] = {}
+ # 파생 복합 모멘텀 캐시: (지수, short, long, weight) → (캐시 version, 시리즈)
+ self._benchmark_composite_cache: dict[tuple, tuple[int, pd.Series]] = {}
logger.info("RelativeStrengthRotationStrategy 초기화 완료")
def _ensure_market_filter(self, dates_index):
- """KS11 > SMA(200) 시장 필터 사전 계산 (lazy cache, 인스턴스당 1회)."""
- if self._mf_series is not None:
+ """KS11 > SMA(N) 시장 필터 계산 (지수는 인스턴스당 넓게 한 번 받아 캐시).
+
+ 예전에는 첫 analyze 호출의 날짜 범위로 한 번만 받아 캐시했다. strict 백테스트의
+ 첫 호출은 df.iloc[:1]이라 1일치 상태가 전 기간에 ffill되어 필터가 한 번도
+ 작동하지 않았다. 이제 넓은 구간을 받아 두고 필터 시리즈를 그 전체로 한 번
+ 계산한 뒤, analyze()가 요청 끝 날짜 이하로 잘라 쓴다. 값은 전일 종가·전일 SMA로만
+ 정해지므로(T-1) 넓게 계산해도 각 날짜의 값은 그날 이전 데이터만 반영한다.
+ """
+ if len(dates_index) == 0:
return
- try:
- from core.data_collector import DataCollector
-
- mf_period = self.params.get("market_filter_ma_period", 200)
- collector = DataCollector()
- first = dates_index.min()
- last = dates_index.max()
- margin = mf_period + 100
- start = (first - pd.Timedelta(days=margin)).strftime("%Y-%m-%d")
- end = last.strftime("%Y-%m-%d")
- ks11 = collector.fetch_korean_stock(
- "KS11", start_date=start, end_date=end
+ mf_period = int(self.params.get("market_filter_ma_period", 200))
+ if self._mf_cache is None or self._mf_cache_period != mf_period:
+ # 거래일 mf_period개를 확보하려면 달력일로 약 1.7배 + 여유가 필요하다.
+ self._mf_cache = IndexCloseCache(
+ "KS11", warmup_days=int(mf_period * 1.7) + 30, label="market_filter",
)
- if ks11 is None or ks11.empty or len(ks11) < mf_period:
- logger.warning(
- "market_filter: KS11 데이터 부족({}/{}) — 필터 비활성화 fallback",
- len(ks11) if ks11 is not None else 0,
- mf_period,
- )
- return
- close_ks = ks11["close"].astype(float)
- sma = close_ks.rolling(mf_period, min_periods=mf_period).mean()
- # T-1 기준: 전일 종가 > 전일 SMA200 → 당일 신규 진입 허용
- self._mf_series = (close_ks > sma).shift(1, fill_value=True).astype(bool)
- except Exception as e:
- logger.warning("market_filter: KS11 로드 실패 — 필터 비활성화: {}", e)
+ self._mf_cache_period = mf_period
+ self._mf_series_key = None
+
+ closes = self._mf_cache.ensure(dates_index.min(), dates_index.max())
+ key = (mf_period, self._mf_cache.version)
+ if key == self._mf_series_key:
+ return
+ self._mf_series_key = key
+
+ if closes is None or len(closes) < mf_period:
+ self._mf_series = None
+ reason = self._mf_cache.last_error or (
+ f"KS11 데이터 부족({0 if closes is None else len(closes)}/{mf_period})"
+ )
+ self.market_filter_status = {"ok": False, "reason": reason}
+ logger.warning(
+ "market_filter: {} — 필터가 적용되지 않습니다 "
+ "(market_filter_active=False로 기록, 이 실행은 필터 없는 결과)",
+ reason,
+ )
+ return
+
+ sma = closes.rolling(mf_period, min_periods=mf_period).mean()
+ # SMA가 아직 없는 날은 '아래'가 아니라 '판단 불가'(NA)로 둔다.
+ above = (closes > sma).astype("boolean")
+ above[sma.isna()] = pd.NA
+ # T-1 기준: 전일 종가 > 전일 SMA → 당일 신규 진입 허용
+ self._mf_series = above.shift(1)
+ self.market_filter_status = {"ok": True, "reason": None}
def _benchmark_composite(
self,
@@ -89,55 +115,42 @@ def _benchmark_composite(
short_w: float,
benchmark_symbol: str,
) -> pd.Series:
- """Return benchmark composite momentum aligned to the input index."""
+ """Return benchmark composite momentum aligned to the input index.
+
+ 예전에는 (시작, 끝) 날짜를 캐시 키로 써서 strict 백테스트가 봉마다 지수를 새로
+ 받았다. 이제 지수를 인스턴스당 넓게 한 번 받아 복합 모멘텀을 한 번 계산하고,
+ 호출의 끝 날짜 이하로 잘라 맞춘다. 조회 실패는 IndexCloseCache가 사유와 함께
+ 경고하고, 여기서는 NaN(진입 차단)을 준다.
+ """
if len(index) == 0:
return pd.Series(dtype=float, index=index)
- try:
- from core.data_collector import DataCollector
-
- dates = pd.to_datetime(index)
- margin_days = max(long_lb * 3, 180)
- start = (dates.min() - pd.Timedelta(days=margin_days)).strftime("%Y-%m-%d")
- end = dates.max().strftime("%Y-%m-%d")
- cache_key = (
- benchmark_symbol,
- int(short_lb),
- int(long_lb),
- float(short_w),
- start,
- end,
- )
- if cache_key in self._benchmark_composite_cache:
- cached = self._benchmark_composite_cache[cache_key]
- aligned = cached.reindex(dates, method="ffill")
- return pd.Series(aligned.to_numpy(), index=index)
-
- collector = DataCollector()
- collector.quiet_ohlcv_log = True
- benchmark = collector.fetch_korean_stock(
- benchmark_symbol,
- start_date=start,
- end_date=end,
+ dates = pd.to_datetime(index)
+ margin_days = max(int(long_lb) * 3, 180)
+ index_key = (benchmark_symbol, margin_days)
+ cache = self._benchmark_index_caches.get(index_key)
+ if cache is None:
+ cache = IndexCloseCache(
+ benchmark_symbol, warmup_days=margin_days, label="benchmark-aware rotation",
)
- if benchmark is None or benchmark.empty:
- logger.warning("benchmark-aware rotation: benchmark data unavailable")
- return pd.Series(np.nan, index=index)
-
- if "date" in benchmark.columns:
- benchmark = benchmark.set_index("date")
- benchmark.index = pd.to_datetime(benchmark.index)
- close = benchmark["close"].astype(float)
- ret_short = close.pct_change(short_lb)
- ret_long = close.pct_change(long_lb)
- benchmark_composite = short_w * ret_short + (1.0 - short_w) * ret_long
- self._benchmark_composite_cache[cache_key] = benchmark_composite
- aligned = benchmark_composite.reindex(dates, method="ffill")
- return pd.Series(aligned.to_numpy(), index=index)
- except Exception as e:
- logger.warning("benchmark-aware rotation disabled: {}", e)
+ self._benchmark_index_caches[index_key] = cache
+
+ closes = cache.ensure(dates.min(), dates.max())
+ if closes is None or closes.empty:
return pd.Series(np.nan, index=index)
+ cache_key = (benchmark_symbol, int(short_lb), int(long_lb), float(short_w))
+ cached = self._benchmark_composite_cache.get(cache_key)
+ if cached is None or cached[0] != cache.version:
+ ret_short = closes.pct_change(short_lb)
+ ret_long = closes.pct_change(long_lb)
+ benchmark_composite = short_w * ret_short + (1.0 - short_w) * ret_long
+ cached = (cache.version, benchmark_composite)
+ self._benchmark_composite_cache[cache_key] = cached
+ # 호출 끝 날짜 이후 봉은 잘라 낸다 (strict 백테스트에서 벤치마크 미래 정보 차단)
+ aligned = cached[1].loc[: dates.max()].reindex(dates, method="ffill")
+ return pd.Series(aligned.to_numpy(), index=index)
+
def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
"""모멘텀 지표 계산 + 월간 리밸런싱 signal 생성."""
analyzed = self.indicator_engine.calculate_all(df.copy())
@@ -236,15 +249,22 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
analyzed["abs_mom_pass"] = abs_pass
# ── 시장 필터: KS11 > SMA200 (T-1 기준) ──
+ # 판단 불가(지수 조회 실패·SMA 미형성) 날은 진입을 막지도, 청산을 강제하지도 않고
+ # market_filter_active=False로 기록한다 — 필터가 켜진 설정인데 실제로는 적용되지
+ # 않은 실행을 연구 리포트가 걸러낼 수 있게.
if self.params.get("market_filter_sma200", False):
self._ensure_market_filter(analyzed.index)
+ market_filter_active = pd.Series(False, index=analyzed.index)
if self._mf_series is not None:
- mf_aligned = self._mf_series.reindex(analyzed.index, method="ffill")
- market_filter_pass = mf_aligned.astype("boolean").fillna(True).astype(bool)
+ mf = self._mf_series.loc[: analyzed.index.max()]
+ mf_aligned = mf.reindex(analyzed.index, method="ffill").astype("boolean")
+ market_filter_active = mf_aligned.notna().astype(bool)
+ market_filter_pass = mf_aligned.fillna(True).astype(bool)
entry_cond = entry_cond & market_filter_pass
analyzed["market_filter_pass"] = market_filter_pass
else:
analyzed["market_filter_pass"] = True
+ analyzed["market_filter_active"] = market_filter_active
market_filter_exit = (
self.params.get("market_filter_sma200", False)
and self.params.get("market_filter_exit", False)
@@ -340,6 +360,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
"rebalance_day": bool(last.get("rebalance_day", False)),
"above_trend": bool(last.get("above_trend", False)),
"market_filter_pass": bool(last.get("market_filter_pass", True)),
+ "market_filter_active": bool(last.get("market_filter_active", False)),
"market_filter_exit": bool(last.get("market_filter_exit", False)),
},
"date": last.name if hasattr(last, "name") else None,
diff --git a/strategies/trend_following.py b/strategies/trend_following.py
index 99509173..24942c4f 100644
--- a/strategies/trend_following.py
+++ b/strategies/trend_following.py
@@ -22,9 +22,12 @@ class TrendFollowingStrategy(BaseStrategy):
조건:
1. ADX > threshold → 강한 추세 존재
- 2. 가격 > 200일선 → 상승 추세
+ 2. 가격 > 추세 이동평균(trend_ma_period, 기본 200일선) → 상승 추세
3. MACD 골든크로스 → 매수 진입
+ 손절·트레일링 배수는 이 전략 설정이 아니라 risk_params.yaml(stop_loss/trailing_stop)을
+ 따른다. 백테스터와 paper 주문 실행이 모두 그 값을 쓰기 때문이다.
+
진입이 늦는 구조이므로 손익비(Profit Factor) ≥ 2.0 달성 여부를 반드시 검증하세요.
한국 시장에서는 추세 지속성이 미국(나스닥)보다 약해 실증 근거가 상대적으로 적으므로, 종목·기간별 검증 권장.
"""
@@ -43,6 +46,10 @@ def __init__(self, config: Config = None):
self.params = self.config.strategies.get("trend_following", {})
logger.info("TrendFollowingStrategy 초기화 완료")
+ def _trend_ma_period(self) -> int:
+ """추세 판단 이동평균 기간 (trend_ma_period, 기본 200)."""
+ return max(2, int(self.params.get("trend_ma_period", 200)))
+
def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
"""모든 지표 계산 + 전략 signal 컬럼 추가"""
analyzed = self.indicator_engine.calculate_all(df.copy())
@@ -50,16 +57,21 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
return analyzed
adx_threshold = self.params.get("adx_threshold", 25)
+ trend_period = self._trend_ma_period()
adx = analyzed.get("adx", pd.Series(np.nan, index=analyzed.index))
close = analyzed.get("close", pd.Series(np.nan, index=analyzed.index))
- sma_200 = analyzed.get("sma_200", pd.Series(np.nan, index=analyzed.index))
+ # 추세선은 전략이 직접 계산한다. 예전에는 IndicatorEngine의 sma_200을 고정으로 읽어
+ # 최적화기가 탐색하던 trend_ma_period가 아무 효과가 없었다(IndicatorEngine은
+ # indicators.moving_average.trend_period 기간만, 그것도 행 수가 충분할 때만 만든다).
+ trend_ma = close.rolling(window=trend_period, min_periods=trend_period).mean()
macd = analyzed.get("macd", pd.Series(np.nan, index=analyzed.index))
macd_signal = analyzed.get("macd_signal", pd.Series(np.nan, index=analyzed.index))
has_trend = adx > adx_threshold
- above_200 = sma_200.notna() & (sma_200 > 0) & (close > sma_200)
- below_200 = sma_200.notna() & (sma_200 > 0) & (close < sma_200)
+ above_200 = trend_ma.notna() & (trend_ma > 0) & (close > trend_ma)
+ below_200 = trend_ma.notna() & (trend_ma > 0) & (close < trend_ma)
+ analyzed["trend_ma"] = trend_ma
macd_golden = (
macd.notna() & macd_signal.notna()
@@ -88,16 +100,22 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
"""추세 추종 신호 생성"""
analyzed = self.analyze(df)
+ trend_period = self._trend_ma_period()
- if analyzed.empty or len(analyzed) < 200:
- return {"signal": self.HOLD, "score": 0, "details": {"이유": "데이터 부족(200일 필요)"}}
+ if analyzed.empty or len(analyzed) < trend_period:
+ return {
+ "signal": self.HOLD,
+ "score": 0,
+ "details": {"이유": f"데이터 부족({trend_period}일 필요)"},
+ }
last = analyzed.iloc[-1]
prev = analyzed.iloc[-2] if len(analyzed) >= 2 else last
adx = last.get("adx", 0)
close = last.get("close", 0)
- sma_200 = last.get("sma_200", 0)
+ trend_ma = last.get("trend_ma", 0)
+ trend_label = f"{trend_period}일선"
macd = last.get("macd", 0)
macd_signal = last.get("macd_signal", 0)
prev_macd = prev.get("macd", 0)
@@ -117,10 +135,10 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
if has_trend:
reasons.append(f"ADX={adx:.1f} > {adx_threshold}")
- # 조건 2: 상승 추세 (200일선 위)
- above_200 = pd.notna(sma_200) and sma_200 > 0 and close > sma_200
+ # 조건 2: 상승 추세 (추세선 위, 기본 200일선)
+ above_200 = pd.notna(trend_ma) and trend_ma > 0 and close > trend_ma
if above_200:
- reasons.append("종가 > 200일선")
+ reasons.append(f"종가 > {trend_label}")
# 조건 3: MACD 골든크로스
macd_golden = (
@@ -130,7 +148,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
if macd_golden:
reasons.append("MACD 골든크로스")
- below_200 = pd.notna(sma_200) and sma_200 > 0 and close < sma_200
+ below_200 = pd.notna(trend_ma) and trend_ma > 0 and close < trend_ma
macd_dead = (
pd.notna(macd) and pd.notna(macd_signal) and
macd < macd_signal and prev_macd >= prev_signal
@@ -138,7 +156,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
if macd_dead:
reasons.append("MACD 데드크로스")
if below_200:
- reasons.append("종가 < 200일선")
+ reasons.append(f"종가 < {trend_label}")
return {
"signal": signal,
@@ -146,7 +164,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
"details": {
"ADX": round(adx, 2) if pd.notna(adx) else 0,
"종가": close,
- "200일선": round(sma_200, 0) if pd.notna(sma_200) else 0,
+ trend_label: round(trend_ma, 0) if pd.notna(trend_ma) else 0,
"MACD": round(macd, 2) if pd.notna(macd) else 0,
"조건": ", ".join(reasons) if reasons else "없음",
},
diff --git a/tests/conftest.py b/tests/conftest.py
index 71bdbd39..6aee600f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -25,6 +25,15 @@ def pytest_configure(config):
# 운영 바스켓의 '추세 아래/낙폭 발동' 상태를 덮어쓰지 않도록 임시 디렉터리로 격리한다.
if not os.environ.get("QUANT_OVERLAY_STATE_DIR"):
os.environ["QUANT_OVERLAY_STATE_DIR"] = tempfile.mkdtemp(prefix="quant_test_overlay_")
+ # 대시보드 런타임 상태·live 런타임 락도 운영 파일이다(2026-09-23 확인: 테스트 실행이
+ # data/dashboard_runtime_state.json에 가짜 신호를 남기고 data/.live_runtime.lock을
+ # 잡았다 — 같은 시각의 실제 live 실행은 '다른 런타임 실행 중'으로 중단된다).
+ if not os.environ.get("QUANT_DASHBOARD_STATE_PATH"):
+ os.environ["QUANT_DASHBOARD_STATE_PATH"] = str(
+ Path(tempfile.mkdtemp(prefix="quant_test_dash_")) / "dashboard_runtime_state.json"
+ )
+ if not os.environ.get("QUANT_RUNTIME_LOCK_DIR"):
+ os.environ["QUANT_RUNTIME_LOCK_DIR"] = tempfile.mkdtemp(prefix="quant_test_lock_")
# 사용자가 명시적으로 DB 경로를 지정했다면 존중한다.
if os.environ.get("QUANT_DB_PATH"):
@@ -117,3 +126,37 @@ def _isolate_global_trading_halt():
except Exception:
# 스키마 미생성 등으로 정리에 실패해도 테스트 결과를 바꾸지 않는다.
pass
+
+
+
+# ---------------------------------------------------------------------------
+# 연구 증거·런타임 원장 격리
+#
+# paper_evidence/paper_runtime/paper_pilot은 reports/ 아래 상대 경로에 원장을 쓴다.
+# 개별 테스트가 경로를 바꾸는 걸 잊으면 운영 원장에 테스트 기록이 쌓인다 —
+# 2026-09-23 확인: 테스트 실행이 reports/paper_evidence/daily_evidence_scoring.jsonl에
+# execution_backed=True인 '실제 paper' 기록을 남기고 pilot_audit.jsonl을 늘렸다.
+# 먼저 임시 경로로 돌려 두고, 경로를 직접 지정하는 테스트는 그 위에 덮어쓴다.
+# ---------------------------------------------------------------------------
+@pytest.fixture(autouse=True)
+def _isolate_research_ledgers(tmp_path_factory, monkeypatch):
+ base = tmp_path_factory.mktemp("ledgers")
+ targets = (
+ ("core.paper_evidence", "EVIDENCE_DIR", base / "paper_evidence"),
+ ("core.paper_evidence", "PROMOTION_DIR", base / "promotion"),
+ ("core.evidence_collector", "EVIDENCE_DIR", base / "paper_evidence"),
+ ("core.paper_runtime", "RUNTIME_DIR", base / "paper_runtime"),
+ ("core.paper_runtime", "APPROVED_STRATEGIES_PATH", base / "approved_strategies.json"),
+ ("core.paper_preflight", "RUNTIME_DIR", base / "paper_runtime"),
+ ("core.paper_pilot", "RUNTIME_DIR", base / "paper_runtime"),
+ ("core.paper_pilot", "PILOT_AUTH_FILE", base / "paper_runtime" / "pilot_authorizations.jsonl"),
+ ("core.paper_pilot", "PILOT_AUDIT_FILE", base / "paper_runtime" / "pilot_audit.jsonl"),
+ )
+ for module_name, attr, path in targets:
+ try:
+ module = __import__(module_name, fromlist=[attr])
+ except Exception:
+ continue
+ if hasattr(module, attr):
+ monkeypatch.setattr(module, attr, path)
+ yield
diff --git a/tests/js/dashboard.test.cjs b/tests/js/dashboard.test.cjs
index 08394b89..1907b3f9 100644
--- a/tests/js/dashboard.test.cjs
+++ b/tests/js/dashboard.test.cjs
@@ -82,7 +82,7 @@ function dashboard({ now = "2026-10-01T00:15:00+09:00", fetch } = {}) {
history: { replaceState() {} },
});
const exports =
- "parseDate, fmtLong, fmtDT, calendarAgeDays, rowInstant, buildSeries, monthlyReturns, currentMonthContributionState, renderSignals, refreshCore, refreshSlow, refreshChart, updateChart, renderChartTable, moveHistoryTable, state, chart";
+ "parseDate, fmtLong, fmtDT, calendarAgeDays, rowInstant, buildSeries, monthlyReturns, currentMonthContributionState, renderSignals, refreshCore, refreshSlow, refreshChart, updateChart, renderChartTable, moveHistoryTable, renderDecision, renderEvaluations, state, chart";
assert.match(source, /\n main\(\);\s*\}\)\(\);\s*$/);
vm.runInContext(
source.replace(/\n main\(\);/, `\n globalThis.dashboard = {${exports}};`),
@@ -338,3 +338,31 @@ test("운영 상태 새로고침도 진행 중인 조회 결과를 함께 기다
assert.equal(calls.length, 2);
assert.ok(calls.every((c) => !c.signal.aborted));
});
+
+test("검증 결과를 처음 불러오는 동안에는 실패 안내를 띄우지 않음", () => {
+ const d = dashboard();
+ d.state.runtime = { trading_halt: null, scheduler_stale: false };
+ d.state.runtimeStatus = "ready";
+ d.state.baskets = [
+ {
+ basket: "kr_pocket",
+ account_key: "basket_rebalance:kr_pocket",
+ snapshot: { date: "2026-09-30" },
+ missed_trading_days: 0,
+ contribution_plan: { enabled: false },
+ },
+ ];
+ const title = () => d.element("decisionTitle").textContent;
+
+ d.renderDecision();
+ assert.equal(title(), "검증 결과를 확인하고 있습니다");
+
+ d.renderEvaluations([{ basket: "kr_pocket", issues: [] }]);
+ d.renderDecision();
+ assert.equal(title(), "현재 확인할 항목이 없습니다");
+
+ d.state.evaluations = null;
+ d.state.evaluationsStatus = "error";
+ d.renderDecision();
+ assert.equal(title(), "검증 결과를 불러오지 못했습니다");
+});
diff --git a/tests/test_audit_backtest_metrics.py b/tests/test_audit_backtest_metrics.py
new file mode 100644
index 00000000..9f00224f
--- /dev/null
+++ b/tests/test_audit_backtest_metrics.py
@@ -0,0 +1,265 @@
+"""백테스트 성과 지표 감사 회귀 테스트.
+
+- 보유기간 만료(MAX_HOLD) 청산이 승률·손익비·거래 수·보유 기간에서 빠지지 않는다.
+- 칼마 비율은 부호 있는 CAGR / |MDD|.
+- 샤프·소르티노의 무위험수익률은 이름 있는 상수 하나(3%)이고 리포트에 표기된다.
+"""
+
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+
+
+class _SingleConfig:
+ """단일 종목 Backtester용 최소 설정 (외부 데이터·비용 없음)."""
+
+ settings = {}
+ strategies = {}
+
+ def __init__(self, *, max_holding_days=0, min_holding_days=0):
+ self._trading = {"skip_earnings_days": 0}
+ self._risk_params = {
+ "transaction_costs": {
+ "commission_rate": 0.0,
+ "tax_rate": 0.0,
+ "slippage": 0.0,
+ "slippage_ticks": 0,
+ "dynamic_slippage": {"enabled": False},
+ },
+ "stop_loss": {"type": "fixed", "fixed_rate": 0.50},
+ "take_profit": {"fixed_rate": 0.50, "partial_exit": False},
+ "trailing_stop": {"enabled": False},
+ "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 1_000_000},
+ "diversification": {"max_position_ratio": 0.20, "max_investment_ratio": 0.70},
+ "position_limits": {
+ "min_holding_days": min_holding_days,
+ "max_holding_days": max_holding_days,
+ "max_monthly_roundtrips": 0,
+ },
+ "liquidity_filter": {"backtest_max_participation_rate": 1.0},
+ "gap_risk": {"enabled": False},
+ "blackswan": {"enabled": False},
+ "backtest_regime_filter": {"enabled": False},
+ }
+
+ @property
+ def risk_params(self):
+ return self._risk_params
+
+ @property
+ def trading(self):
+ return self._trading
+
+
+def _frame(close, *, open_=None, signals=None, start="2024-01-01"):
+ dates = pd.bdate_range(start, periods=len(close))
+ close = np.asarray(close, dtype=float)
+ open_ = close if open_ is None else np.asarray(open_, dtype=float)
+ df = pd.DataFrame(
+ {
+ "open": open_,
+ "high": np.maximum(open_, close),
+ "low": np.minimum(open_, close),
+ "close": close,
+ "volume": [1_000_000] * len(close),
+ "signal": signals or ["HOLD"] * len(close),
+ },
+ index=dates,
+ )
+ df["_avg_daily_volume"] = df["volume"]
+ return df
+
+
+def test_max_hold_exit_is_counted_in_trade_metrics():
+ """30일 만료로 닫힌 거래가 거래 수·승률·보유 기간에 그대로 잡혀야 한다."""
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_SingleConfig(max_holding_days=30))
+ n = 30
+ close = 100.0 - np.arange(n) * 0.1 # 손절·익절에 걸리지 않는 완만한 하락
+ signals = ["BUY"] + ["HOLD"] * (n - 1)
+ df = _frame(close, signals=signals)
+
+ result = bt._simulate(df, initial_capital=1_000_000.0)
+ actions = [t["action"] for t in result["trades"]]
+ assert actions == ["BUY", "MAX_HOLD"]
+ buy, max_hold = result["trades"]
+ assert (max_hold["date"] - buy["date"]).days == 30
+ assert max_hold["pnl"] < 0
+
+ metrics = bt._calculate_metrics(result, initial_capital=1_000_000.0)
+ assert metrics["total_trades"] == 1
+ assert metrics["losing_trades"] == 1
+ assert metrics["winning_trades"] == 0
+ assert metrics["win_rate"] == 0
+ assert metrics["avg_holding_days"] == pytest.approx(30.0)
+ assert metrics["ev_per_trade"] < 0
+ assert metrics["max_consecutive_losses"] == 1
+
+
+def test_partial_exit_keeps_holding_clock_until_full_exit():
+ """부분 익절 뒤 MAX_HOLD로 잔량이 닫히면 두 매도 모두 같은 매수일 기준으로 보유 기간을 잰다."""
+ from backtest.backtester import Backtester, PNL_EXIT_ACTIONS
+
+ buy_date = pd.Timestamp("2024-01-02")
+ trades = [
+ {"date": buy_date, "action": "BUY", "price": 100.0, "quantity": 10, "pnl": 0},
+ {
+ "date": buy_date + pd.Timedelta(days=5),
+ "action": "TAKE_PROFIT_PARTIAL",
+ "price": 105.0,
+ "quantity": 5,
+ "pnl": 25.0,
+ },
+ {
+ "date": buy_date + pd.Timedelta(days=30),
+ "action": "MAX_HOLD",
+ "price": 99.0,
+ "quantity": 5,
+ "pnl": -5.0,
+ },
+ ]
+ assert {"MAX_HOLD", "TAKE_PROFIT_PARTIAL"} <= PNL_EXIT_ACTIONS
+ equity = pd.DataFrame(
+ {
+ "date": pd.bdate_range("2024-01-02", periods=22),
+ "value": [1_000.0] * 22,
+ }
+ )
+ metrics = Backtester(_SingleConfig())._calculate_metrics(
+ {"equity_curve": equity, "trades": trades}, initial_capital=1_000.0
+ )
+
+ assert metrics["total_trades"] == 2
+ assert metrics["winning_trades"] == 1
+ assert metrics["losing_trades"] == 1
+ assert metrics["avg_holding_days"] == pytest.approx((5 + 30) / 2)
+
+
+# ─── 칼마 비율: 부호 있는 CAGR / |MDD| ────────────────────────────
+
+
+def _equity(values, start="2020-01-01"):
+ values = np.asarray(values, dtype=float)
+ return pd.DataFrame(
+ {
+ "date": pd.bdate_range(start, periods=len(values)),
+ "value": values,
+ "n_positions": [1] * len(values),
+ }
+ )
+
+
+def _single_metrics(values, initial_capital=1_000.0):
+ from backtest.backtester import Backtester
+
+ return Backtester(_SingleConfig())._calculate_metrics(
+ {"equity_curve": _equity(values), "trades": []}, initial_capital=initial_capital
+ )
+
+
+def _portfolio_metrics(values, initial_capital=1_000.0):
+ from backtest.portfolio_backtester import PortfolioBacktester
+
+ return PortfolioBacktester(_SingleConfig())._calculate_portfolio_metrics(
+ {"equity_curve": _equity(values), "trades": []}, initial_capital=initial_capital
+ )
+
+
+@pytest.mark.parametrize("metrics_fn", [_single_metrics, _portfolio_metrics])
+def test_calmar_is_negative_for_a_losing_strategy(metrics_fn):
+ """꾸준히 잃는 전략의 칼마는 음수여야 한다 (예전엔 abs()로 양수)."""
+ metrics = metrics_fn(np.linspace(990.0, 900.0, 252))
+
+ assert metrics["total_return"] < 0
+ assert metrics["max_drawdown"] < 0
+ assert metrics["calmar_ratio"] < 0
+ # 산술 연간 수익률 필드는 그대로 보고한다.
+ assert metrics["annual_return"] == pytest.approx(-10.0, abs=0.01)
+
+
+@pytest.mark.parametrize("metrics_fn", [_single_metrics, _portfolio_metrics])
+def test_calmar_uses_cagr_not_arithmetic_annual_return(metrics_fn):
+ """2년 +21%(CAGR 10%)에 MDD -10%면 칼마는 1.0 (산술 연수익 10.5% 기준이면 1.05)."""
+ values = np.concatenate(([900.0], np.linspace(900.0, 1_210.0, 503)))
+ assert len(values) == 504 # 504 / 252 = 2년
+
+ metrics = metrics_fn(values)
+
+ assert metrics["max_drawdown"] == pytest.approx(-10.0)
+ assert metrics["cagr"] == pytest.approx(10.0, abs=0.01)
+ assert metrics["annual_return"] == pytest.approx(10.5, abs=0.01)
+ assert metrics["calmar_ratio"] == pytest.approx(1.0, abs=0.01)
+
+
+@pytest.mark.parametrize("metrics_fn", [_single_metrics, _portfolio_metrics])
+def test_calmar_is_zero_without_drawdown(metrics_fn):
+ metrics = metrics_fn(np.linspace(1_000.0, 1_100.0, 252))
+
+ assert metrics["max_drawdown"] == 0
+ assert metrics["calmar_ratio"] == 0.0
+
+
+# ─── 샤프 무위험수익률: 이름 있는 상수 하나 + 리포트 표기 ───────────────
+
+
+def test_backtest_risk_free_constant_keeps_three_percent():
+ from backtest.backtester import BACKTEST_RISK_FREE_ANNUAL, BACKTEST_RISK_FREE_LABEL
+
+ # 값을 바꾸면 min_sharpe·OOS 게이트 등 이 기준에 맞춘 문턱을 재기준화해야 한다.
+ assert BACKTEST_RISK_FREE_ANNUAL == 0.03
+ assert BACKTEST_RISK_FREE_LABEL == "금리 3%"
+
+
+def test_both_engines_compute_sharpe_with_the_shared_risk_free_rate():
+ from backtest.backtester import BACKTEST_RISK_FREE_ANNUAL
+
+ rng = np.random.default_rng(7)
+ values = 1_000.0 * np.cumprod(1 + rng.normal(0.0008, 0.01, 300))
+ single = _single_metrics(values)
+ portfolio = _portfolio_metrics(values)
+
+ daily = pd.Series(values).pct_change()
+ daily.iloc[0] = values[0] / 1_000.0 - 1.0 # 두 엔진 모두 첫날을 초기자본 대비로 포함
+ expected = (daily.mean() * 252 - BACKTEST_RISK_FREE_ANNUAL) / (daily.std() * np.sqrt(252))
+
+ assert single["sharpe_ratio"] == pytest.approx(round(expected, 2))
+ assert portfolio["sharpe_ratio"] == single["sharpe_ratio"]
+ assert portfolio["sortino_ratio"] == single["sortino_ratio"]
+
+
+def test_report_labels_show_backtest_risk_free_rate(tmp_path):
+ from backtest.cost_impact import summarize_cost_impact
+ from backtest.report_generator import ReportGenerator
+ from backtest.strategy_validator import StrategyValidator
+
+ metrics = {
+ "initial_capital": 1_000.0, "final_value": 1_010.0, "total_return": 1.0,
+ "annual_return": 1.0, "cagr": 1.0, "sharpe_ratio": 0.4, "max_drawdown": -2.0,
+ "calmar_ratio": 0.5, "total_trades": 0, "win_rate": 0.0, "winning_trades": 0,
+ "losing_trades": 0, "profit_factor": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
+ "total_commission": 0.0, "total_tax": 0.0, "total_slippage_cost": 0.0,
+ "commission_to_profit_ratio": None, "monthly_roundtrips_per_symbol": 0.0,
+ "annual_roundtrips_total": 0.0,
+ }
+ metrics["cost_impact"] = summarize_cost_impact(metrics)
+ result = {
+ "strategy": "rf_label",
+ "period": "2024-01-02 ~ 2024-12-30",
+ "metrics": metrics,
+ "trades": [],
+ "equity_curve": pd.DataFrame(),
+ }
+ rg = ReportGenerator(output_dir=str(tmp_path))
+
+ text = rg.generate_text_report(result)
+ sharpe_line = next(line for line in text.splitlines() if "샤프 지수" in line)
+ assert "금리 3%" in sharpe_line
+
+ html = Path(rg.generate_html_report(result, filename="rf_label.html")).read_text(encoding="utf-8")
+ assert "샤프 지수 (금리 3%)" in html
+
+ section = StrategyValidator._format_section("OUT_OF_SAMPLE", metrics, {"sharpe_ratio": 0.1})
+ assert section.count("샤프(금리 3%)") == 2
diff --git a/tests/test_audit_backtest_portfolio.py b/tests/test_audit_backtest_portfolio.py
new file mode 100644
index 00000000..dfeeb5ab
--- /dev/null
+++ b/tests/test_audit_backtest_portfolio.py
@@ -0,0 +1,267 @@
+"""PortfolioBacktester 감사 회귀 테스트.
+
+next_open 체결 순서: (1) 시가 청산(갭다운 → 예약 SELL) → (2) 시가 매수 → (3) 종가 청산.
+종가 사건이 먼저 도착한 시가 주문을 밀어내거나, 종가에 판 종목을 같은 날 시가에 다시 사는
+시간 역행이 없어야 한다.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+
+class _PortfolioConfig:
+ settings = {}
+ strategies = {}
+
+ def __init__(self, *, gap_enabled=False, take_profit=0.08, max_holding_days=0):
+ self._trading = {"skip_earnings_days": 0}
+ self._risk_params = {
+ "transaction_costs": {
+ "commission_rate": 0.0,
+ "tax_rate": 0.0,
+ "slippage": 0.0,
+ "slippage_ticks": 0,
+ "dynamic_slippage": {"enabled": False},
+ },
+ "stop_loss": {"type": "fixed", "fixed_rate": 0.03},
+ "take_profit": {"fixed_rate": take_profit},
+ "trailing_stop": {"enabled": False},
+ "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 100_000},
+ "diversification": {
+ "max_positions": 10,
+ "max_position_ratio": 0.20,
+ "max_investment_ratio": 0.95,
+ "min_cash_ratio": 0.0,
+ },
+ "position_limits": {"max_holding_days": max_holding_days},
+ "backtest_regime_filter": {"enabled": False},
+ "gap_risk": {
+ "enabled": gap_enabled,
+ "gap_down_threshold": -0.03,
+ "gap_up_entry_block": 0.05,
+ },
+ "blackswan": {"enabled": False},
+ }
+
+ @property
+ def risk_params(self):
+ return self._risk_params
+
+ @property
+ def trading(self):
+ return self._trading
+
+
+def _signal_df(close, *, open_=None, signals=None, start="2024-01-01", dates=None):
+ dates = pd.bdate_range(start, periods=len(close)) if dates is None else pd.DatetimeIndex(dates)
+ close = np.asarray(close, dtype=float)
+ open_ = close if open_ is None else np.asarray(open_, dtype=float)
+ return pd.DataFrame(
+ {
+ "open": open_,
+ "high": np.maximum(open_, close),
+ "low": np.minimum(open_, close),
+ "close": close,
+ "volume": [1_000_000] * len(close),
+ "signal": signals or ["HOLD"] * len(close),
+ "strategy_score": [3.0] * len(close),
+ "atr": close * 0.02,
+ },
+ index=dates,
+ )
+
+
+def _run(signals: dict, config, **kwargs):
+ from backtest.portfolio_backtester import PortfolioBacktester
+
+ all_dates = sorted(set().union(*(df.index for df in signals.values())))
+ return PortfolioBacktester(config)._simulate_portfolio(
+ symbols=list(signals.keys()),
+ signals=signals,
+ data={},
+ all_dates=all_dates,
+ initial_capital=100_000.0,
+ **kwargs,
+ )
+
+
+def _actions(result):
+ return [
+ (t["symbol"], t["action"], t["date"].strftime("%Y-%m-%d"), t["price"])
+ for t in result["trades"]
+ ]
+
+
+# ─── (1) 시가 주문이 같은 날 종가 사건보다 먼저 ────────────────────
+
+
+def test_pending_sell_fills_at_open_before_close_take_profit():
+ """전일 SELL 신호는 시가에 체결된다. 그날 종가가 익절선을 넘었어도 TAKE_PROFIT로 바뀌지 않는다."""
+ df = _signal_df(
+ [100.0, 100.0, 109.0],
+ open_=[100.0, 100.0, 100.0],
+ signals=["BUY", "SELL", "HOLD"],
+ )
+
+ result = _run({"AAA": df}, _PortfolioConfig(take_profit=0.08))
+
+ assert _actions(result) == [
+ ("AAA", "BUY", "2024-01-02", 100.0),
+ ("AAA", "SELL", "2024-01-03", 100.0),
+ ]
+ assert result["trades"][1]["signal_date"] == df.index[1]
+ assert result["exit_reason_counts"] == {"SELL": 1}
+
+
+def test_gap_down_at_open_takes_precedence_over_pending_sell():
+ df = _signal_df(
+ [100.0, 100.0, 96.0],
+ open_=[100.0, 100.0, 96.0],
+ signals=["BUY", "SELL", "HOLD"],
+ )
+
+ result = _run({"AAA": df}, _PortfolioConfig(gap_enabled=True))
+
+ assert _actions(result) == [
+ ("AAA", "BUY", "2024-01-02", 100.0),
+ ("AAA", "GAP_DOWN", "2024-01-03", 96.0),
+ ]
+ assert result["exit_reason_counts"] == {"GAP_DOWN": 1}
+ assert result["executed_sell_count"] == 1
+ assert result["gap_down_exits"] == 1
+
+
+def test_no_rebuy_at_open_of_the_day_a_position_is_stopped_at_the_close():
+ """보유 중 BUY 신호가 이어져도, 그날 종가 손절 뒤 같은 날 시가 매수는 생기지 않는다."""
+ df = _signal_df(
+ [100.0, 100.0, 96.0],
+ open_=[100.0, 100.0, 100.0],
+ signals=["BUY", "BUY", "HOLD"],
+ )
+
+ result = _run({"AAA": df}, _PortfolioConfig())
+
+ assert _actions(result) == [
+ ("AAA", "BUY", "2024-01-02", 100.0),
+ ("AAA", "STOP_LOSS", "2024-01-03", 96.0),
+ ]
+
+
+def test_position_bought_at_open_is_still_subject_to_close_stop():
+ """시가에 산 종목도 그날 종가 손절선 아래면 종가에 손절된다 (시간 순서상 정상)."""
+ df = _signal_df([100.0, 96.0], open_=[100.0, 100.0], signals=["BUY", "HOLD"])
+
+ result = _run({"AAA": df}, _PortfolioConfig())
+
+ assert _actions(result) == [
+ ("AAA", "BUY", "2024-01-02", 100.0),
+ ("AAA", "STOP_LOSS", "2024-01-02", 96.0),
+ ]
+
+
+def test_pending_buy_on_gap_down_open_is_not_sold_at_the_same_open():
+ df = _signal_df([100.0, 96.0, 96.0], open_=[100.0, 96.0, 96.0], signals=["BUY", "HOLD", "HOLD"])
+
+ result = _run({"AAA": df}, _PortfolioConfig(gap_enabled=True))
+
+ assert _actions(result) == [("AAA", "BUY", "2024-01-02", 96.0)]
+ assert result["gap_down_exits"] == 0
+
+
+def test_symbol_sold_at_open_is_not_rebought_at_the_same_open():
+ """시가에 예약 SELL로 판 종목은 같은 날 시가 BUY 후보에서 빠진다."""
+ df = _signal_df(
+ [100.0, 100.0, 100.0],
+ open_=[100.0, 100.0, 100.0],
+ signals=["BUY", "SELL", "HOLD"],
+ )
+ # 두 번째 종목이 1/2 BUY 신호를 내 1/3에도 매수 후보 평가가 일어나게 한다.
+ other = _signal_df([100.0] * 3, signals=["HOLD", "BUY", "HOLD"])
+
+ result = _run({"AAA": df, "BBB": other}, _PortfolioConfig())
+
+ aaa = [a for a in _actions(result) if a[0] == "AAA"]
+ assert aaa == [("AAA", "BUY", "2024-01-02", 100.0), ("AAA", "SELL", "2024-01-03", 100.0)]
+
+
+# ─── (2) 시가 주문 수량은 시가 시점 평가로 ─────────────────────────
+
+
+def test_open_orders_are_sized_with_open_marks_not_same_day_close():
+ """AAA가 그날 종가 +50%여도, 시가에 체결되는 BBB 수량은 시가 평가 자산으로 정한다."""
+ aaa = _signal_df(
+ [100.0, 100.0, 150.0, 150.0],
+ open_=[100.0, 100.0, 100.0, 150.0],
+ signals=["BUY", "HOLD", "HOLD", "HOLD"],
+ )
+ bbb = _signal_df([100.0] * 4, signals=["HOLD", "BUY", "HOLD", "HOLD"])
+
+ result = _run({"AAA": aaa, "BBB": bbb}, _PortfolioConfig(take_profit=0.90))
+
+ buys = {t["symbol"]: t for t in result["trades"] if t["action"] == "BUY"}
+ assert buys["AAA"]["quantity"] == 200 # 100,000 × 20% / 100
+ # 시가 평가 자산 = 현금 80,000 + AAA 200주 × 시가 100 = 100,000 → 200주
+ # (당일 종가 150으로 평가하면 110,000 → 220주가 된다)
+ assert buys["BBB"]["date"] == pd.Timestamp("2024-01-03")
+ assert buys["BBB"]["quantity"] == 200
+
+
+# ─── 당일 행이 없는 보유 종목 평가와 데이터 종료 청산 ───────────────
+
+
+def _equity_by_date(result):
+ eq = result["equity_curve"]
+ return dict(zip(eq["date"].dt.strftime("%Y-%m-%d"), eq["value"]))
+
+
+def test_symbol_whose_data_ends_is_valued_at_last_close_then_force_exited():
+ """BBB 데이터가 끝난 다음 날 평균단가(100)로 되돌아가는 가짜 수익 없이 마지막 종가(98)로 청산."""
+ from backtest.backtester import PNL_EXIT_ACTIONS
+ from backtest.portfolio_backtester import PortfolioBacktester
+
+ aaa = _signal_df([100.0] * 10) # 달력을 이어 가는 종목 (거래 없음)
+ bbb = _signal_df([100.0, 100.0, 99.0, 99.0, 98.0, 98.0], signals=["BUY"] + ["HOLD"] * 5)
+
+ result = _run({"AAA": aaa, "BBB": bbb}, _PortfolioConfig())
+
+ assert _actions(result) == [
+ ("BBB", "BUY", "2024-01-02", 100.0),
+ ("BBB", "DATA_END", "2024-01-09", 98.0),
+ ]
+ assert result["data_end_exits"] == 1
+ assert result["exit_reason_counts"] == {"DATA_END": 1}
+
+ equity = _equity_by_date(result)
+ # 매수 200주, 현금 80,000. 마지막 종가 98 → 99,600에서 멈춰야 한다 (예전엔 100,000으로 복귀).
+ assert equity["2024-01-08"] == pytest.approx(99_600.0)
+ assert equity["2024-01-09"] == pytest.approx(99_600.0)
+ assert equity["2024-01-12"] == pytest.approx(99_600.0)
+
+ assert "DATA_END" in PNL_EXIT_ACTIONS
+ metrics = PortfolioBacktester(_PortfolioConfig())._calculate_portfolio_metrics(
+ result, initial_capital=100_000.0
+ )
+ assert metrics["total_trades"] == 1
+ assert metrics["losing_trades"] == 1
+ assert metrics["data_end_exits"] == 1
+
+
+def test_mid_series_missing_row_is_valued_at_last_close_without_exit():
+ """거래정지처럼 중간에 행만 빠진 날은 청산하지 않고 마지막 종가로 평가한다."""
+ aaa = _signal_df([100.0] * 8)
+ dates = pd.bdate_range("2024-01-01", periods=8)
+ bbb_dates = dates.delete(4) # 2024-01-05 결측
+ bbb = _signal_df(
+ [100.0, 100.0, 99.0, 98.0, 98.0, 98.0, 98.0],
+ signals=["BUY"] + ["HOLD"] * 6,
+ dates=bbb_dates,
+ )
+
+ result = _run({"AAA": aaa, "BBB": bbb}, _PortfolioConfig())
+
+ assert _actions(result) == [("BBB", "BUY", "2024-01-02", 100.0)]
+ assert result["data_end_exits"] == 0
+ equity = _equity_by_date(result)
+ assert equity["2024-01-04"] == pytest.approx(99_600.0)
+ assert equity["2024-01-05"] == pytest.approx(99_600.0) # 예전엔 평균단가로 100,000
diff --git a/tests/test_audit_backtest_report.py b/tests/test_audit_backtest_report.py
new file mode 100644
index 00000000..b0fc9d0e
--- /dev/null
+++ b/tests/test_audit_backtest_report.py
@@ -0,0 +1,266 @@
+"""백테스트 리포트 감사 회귀 테스트.
+
+- 리포트 거래표가 엔진 지표와 같은 청산 목록(PNL_EXIT_ACTIONS)을 쓴다.
+- 실전 vs 백테스트 슬리피지 카드는 transaction_costs.slippage를 읽고 '하한'으로 표기한다.
+"""
+
+import re
+from pathlib import Path
+
+import pandas as pd
+import pytest
+
+
+def _report_metrics():
+ from backtest.cost_impact import summarize_cost_impact
+
+ metrics = {
+ "initial_capital": 1_000_000.0,
+ "final_value": 900_000.0,
+ "total_return": -10.0,
+ "annual_return": -10.0,
+ "cagr": -10.0,
+ "sharpe_ratio": -0.5,
+ "max_drawdown": -12.0,
+ "calmar_ratio": -0.83,
+ "total_trades": 3,
+ "win_rate": 0.0,
+ "winning_trades": 0,
+ "losing_trades": 3,
+ "profit_factor": 0.0,
+ "avg_win": 0.0,
+ "avg_loss": 30_000.0,
+ "total_commission": 0.0,
+ "total_tax": 0.0,
+ "total_slippage_cost": 0.0,
+ "commission_to_profit_ratio": None,
+ "monthly_roundtrips_per_symbol": 1.0,
+ "annual_roundtrips_total": 12.0,
+ }
+ metrics["cost_impact"] = summarize_cost_impact(metrics)
+ return metrics
+
+
+def _risk_exit_trades():
+ trades = []
+ for i, action in enumerate(("GAP_DOWN", "BLACKSWAN", "MAX_HOLD")):
+ day = pd.Timestamp("2024-01-02") + pd.Timedelta(days=7 * i)
+ trades.append(
+ {"date": day, "action": "BUY", "price": 100.0, "quantity": 100, "pnl": 0, "pnl_rate": 0}
+ )
+ trades.append(
+ {
+ "date": day + pd.Timedelta(days=3),
+ "action": action,
+ "price": 97.0,
+ "quantity": 100,
+ "pnl": -30_000.0,
+ "pnl_rate": -3.0,
+ }
+ )
+ return trades
+
+
+def test_report_uses_engine_exit_action_set():
+ import backtest.backtester as backtester_mod
+ import backtest.report_generator as report_mod
+
+ assert report_mod.PNL_EXIT_ACTIONS is backtester_mod.PNL_EXIT_ACTIONS
+ assert {"GAP_DOWN", "BLACKSWAN", "MAX_HOLD", "TAKE_PROFIT_PARTIAL"} <= report_mod.PNL_EXIT_ACTIONS
+
+
+def test_text_report_lists_gap_down_and_blackswan_sells(tmp_path):
+ from backtest.report_generator import ReportGenerator
+
+ result = {
+ "strategy": "audit_exit_actions",
+ "period": "2024-01-02 ~ 2024-01-31",
+ "metrics": _report_metrics(),
+ "trades": _risk_exit_trades(),
+ "equity_curve": pd.DataFrame(),
+ }
+
+ text = ReportGenerator(output_dir=str(tmp_path)).generate_text_report(result)
+
+ recent = text.split("[ 최근 매도 거래 (최대 10건) ]", 1)[1]
+ for action in ("GAP_DOWN", "BLACKSWAN", "MAX_HOLD"):
+ assert action in recent
+
+
+def test_html_trades_table_lists_gap_down_and_blackswan_sells():
+ from backtest.report_generator import ReportGenerator
+
+ html = ReportGenerator._generate_trades_table(_risk_exit_trades())
+
+ for action in ("GAP_DOWN", "BLACKSWAN", "MAX_HOLD"):
+ assert f"| {action} | " in html
+
+
+def _emitted_exit_actions(path: str) -> set:
+ src = Path(path).read_text(encoding="utf-8")
+ patterns = (
+ r'"action":\s*"([A-Z_]+)"',
+ r'_execute_full_exit\(\s*"([A-Z_]+)"',
+ r'\bsell_reason\s*=\s*"([A-Z_]+)"',
+ )
+ found = set()
+ for pattern in patterns:
+ found |= set(re.findall(pattern, src))
+ found.discard("BUY")
+ return found
+
+
+def test_every_exit_action_emitted_by_engines_is_in_pnl_exit_actions():
+ """엔진이 새 청산 사유를 기록하면 지표·리포트 공용 목록에도 들어 있어야 한다."""
+ import backtest.backtester as backtester_mod
+ import backtest.portfolio_backtester as portfolio_mod
+
+ single = _emitted_exit_actions(backtester_mod.__file__)
+ portfolio = _emitted_exit_actions(portfolio_mod.__file__)
+
+ # 패턴이 코드 형태 변화로 아무것도 못 찾는 경우를 막기 위한 하한 확인
+ assert {
+ "SELL", "STOP_LOSS", "TAKE_PROFIT", "TAKE_PROFIT_PARTIAL",
+ "TRAILING_STOP", "MAX_HOLD", "GAP_DOWN", "BLACKSWAN",
+ } <= single
+ assert {
+ "SELL", "STOP_LOSS", "TAKE_PROFIT", "TRAILING_STOP",
+ "MAX_HOLD", "GAP_DOWN", "BLACKSWAN",
+ } <= portfolio
+ assert (single | portfolio) <= backtester_mod.PNL_EXIT_ACTIONS
+
+
+# ─── 실전 vs 백테스트 슬리피지 카드 ─────────────────────────────
+
+
+def test_backtest_slippage_reads_transaction_costs_key(monkeypatch):
+ import backtest.report_generator as report_mod
+ from config.config_loader import Config
+
+ class _Cfg:
+ # 최상위 slippage는 RiskManager가 읽지 않는 키 — 리포트도 무시해야 한다.
+ risk_params = {"slippage": 0.009, "transaction_costs": {"slippage": 0.001}}
+
+ monkeypatch.setattr(Config, "get", classmethod(lambda cls: _Cfg()))
+
+ assert report_mod._default_backtest_slippage_pct() == pytest.approx(0.1)
+
+
+def test_live_slippage_card_labels_backtest_value_as_fixed_rate_floor():
+ import backtest.report_generator as report_mod
+
+ summary = {
+ "n": 3,
+ "mean_pct": 0.12,
+ "median_pct": 0.10,
+ "max_abs_pct": 0.30,
+ "backtest_assumed_pct": 0.05,
+ }
+
+ text = "\n".join(report_mod._format_live_slippage_text_table(summary))
+ assert "transaction_costs.slippage" in text
+ assert "하한" in text
+ assert "risk_params.slippage" not in text
+
+ html = report_mod._format_live_slippage_html_card(summary)
+ assert "고정 비율 하한" in html
+
+
+# ─── 시장 국면별 성과: 월수익률·국면 MDD 공식 ──────────────────────
+
+
+def _q1_2024_dates():
+ return pd.bdate_range("2024-01-01", "2024-03-29")
+
+
+def _step_series(dates, jan, feb_mar):
+ """1월은 jan, 2월 첫 거래일부터는 feb_mar (2월 첫날 갭 하락 후 보합)."""
+ return [jan if d.month == 1 else feb_mar for d in dates]
+
+
+def test_strategy_monthly_return_keeps_month_first_day_move():
+ from backtest.report_generator import _strategy_monthly_returns
+
+ dates = _q1_2024_dates()
+ equity = pd.DataFrame({"date": dates, "value": _step_series(dates, 100.0, 90.0)})
+
+ rets = _strategy_monthly_returns(equity, initial_capital=100.0)
+
+ assert rets[pd.Period("2024-01", "M")] == pytest.approx(0.0)
+ assert rets[pd.Period("2024-02", "M")] == pytest.approx(-0.10)
+ assert rets[pd.Period("2024-03", "M")] == pytest.approx(0.0)
+
+
+def test_strategy_first_month_is_measured_from_initial_capital():
+ from backtest.report_generator import _strategy_monthly_returns
+
+ dates = pd.bdate_range("2024-01-01", "2024-01-31")
+ equity = pd.DataFrame({"date": dates, "value": [95.0] * len(dates)})
+
+ assert _strategy_monthly_returns(equity, initial_capital=100.0).iloc[0] == pytest.approx(-0.05)
+ # 초기자본을 모르면 첫 관측값 기준 (기존 동작과 같음)
+ assert _strategy_monthly_returns(equity).iloc[0] == pytest.approx(0.0)
+
+
+def test_kospi_first_month_uses_close_before_start():
+ from backtest.report_generator import _kospi_monthly_returns_from_ohlc
+
+ dates = pd.bdate_range("2023-12-20", "2024-01-31")
+ close = [100.0 if d.year == 2023 else 105.0 for d in dates] # 1월 첫 거래일 +5% 갭
+ ks11 = pd.DataFrame({"close": close}, index=dates)
+
+ rets = _kospi_monthly_returns_from_ohlc(ks11, start=pd.Timestamp("2024-01-01"))
+
+ assert list(rets.index) == [pd.Period("2024-01", "M")]
+ assert rets.iloc[0] == pytest.approx(0.05)
+
+
+def test_regime_mdd_only_compounds_that_regimes_months():
+ from backtest.report_generator import _mdd_from_monthly_returns
+
+ # {1월, 3월}이 보합이면 사이의 2월 손실과 무관하게 MDD 0
+ assert _mdd_from_monthly_returns([0.0, 0.0]) == pytest.approx(0.0)
+ # 한 달만 있어도 그 달의 손실은 낙폭이다.
+ assert _mdd_from_monthly_returns([-0.10]) == pytest.approx(-10.0)
+ assert _mdd_from_monthly_returns([0.10, -0.20, 0.05]) == pytest.approx(-20.0)
+ assert _mdd_from_monthly_returns([]) == 0.0
+
+
+def test_market_regime_breakdown_classifies_month_opening_gap(monkeypatch):
+ """2월 첫 거래일 -10% 갭은 2월을 하락장으로 분류하고, 보합인 1·3월 MDD에 섞이지 않는다."""
+ from backtest.report_generator import (
+ REGIME_BEAR,
+ REGIME_SIDEWAYS,
+ compute_market_regime_breakdown,
+ )
+
+ ks_dates = pd.bdate_range("2023-12-15", "2024-03-29")
+ ks11 = pd.DataFrame(
+ {"close": [2_500.0 if d < pd.Timestamp("2024-02-01") else 2_250.0 for d in ks_dates]},
+ index=ks_dates,
+ )
+
+ class FakeCollector:
+ def fetch_korean_stock(self, symbol, start_date=None, end_date=None):
+ assert symbol == "KS11"
+ return ks11.loc[pd.Timestamp(start_date): pd.Timestamp(end_date)].copy()
+
+ monkeypatch.setattr("core.data_collector.DataCollector", FakeCollector)
+
+ dates = _q1_2024_dates()
+ equity = pd.DataFrame({"date": dates, "value": _step_series(dates, 100.0, 90.0)})
+ breakdown = compute_market_regime_breakdown(
+ {"equity_curve": equity, "initial_capital": 100.0},
+ warn_bear_underperformance=False,
+ )
+
+ bear = breakdown[REGIME_BEAR]
+ assert bear["n_months"] == 1
+ assert bear["avg_strat_pct"] == pytest.approx(-10.0)
+ assert bear["avg_kospi_pct"] == pytest.approx(-10.0)
+ assert bear["excess_pct"] == pytest.approx(0.0)
+ assert bear["mdd_pct"] == pytest.approx(-10.0)
+
+ sideways = breakdown[REGIME_SIDEWAYS]
+ assert sideways["n_months"] == 2
+ assert sideways["mdd_pct"] == pytest.approx(0.0)
diff --git a/tests/test_audit_backtest_single.py b/tests/test_audit_backtest_single.py
new file mode 100644
index 00000000..b2221e00
--- /dev/null
+++ b/tests/test_audit_backtest_single.py
@@ -0,0 +1,223 @@
+"""단일 종목 Backtester 체결 순서·보유 규칙 감사 회귀 테스트.
+
+- next_open에서 시가에 산 주식을 같은 시가에 갭다운으로 되팔지 않는다.
+- 최소 보유 기간에는 실전처럼 손실 방어 청산(손절·트레일링·갭다운·블랙스완)만 허용한다.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+
+class _GuardConfig:
+ settings = {}
+ strategies = {}
+
+ def __init__(
+ self,
+ *,
+ gap_enabled=True,
+ min_holding_days=0,
+ max_holding_days=0,
+ partial_exit=False,
+ take_profit=0.50,
+ stop_loss=0.03,
+ trailing=False,
+ ):
+ self._trading = {"skip_earnings_days": 0}
+ self._risk_params = {
+ "transaction_costs": {
+ "commission_rate": 0.0,
+ "tax_rate": 0.0,
+ "slippage": 0.0,
+ "slippage_ticks": 0,
+ "dynamic_slippage": {"enabled": False},
+ },
+ "stop_loss": {"type": "fixed", "fixed_rate": stop_loss},
+ "take_profit": {
+ "fixed_rate": take_profit,
+ "partial_exit": partial_exit,
+ "partial_ratio": 0.5,
+ "partial_target": 0.04,
+ },
+ "trailing_stop": {"enabled": trailing, "type": "fixed", "fixed_rate": 0.05},
+ "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 100_000},
+ "diversification": {"max_position_ratio": 0.20, "max_investment_ratio": 0.70},
+ "position_limits": {
+ "min_holding_days": min_holding_days,
+ "max_holding_days": max_holding_days,
+ "max_monthly_roundtrips": 0,
+ },
+ "liquidity_filter": {"backtest_max_participation_rate": 1.0},
+ "gap_risk": {
+ "enabled": gap_enabled,
+ "gap_down_threshold": -0.03,
+ "gap_up_entry_block": 0.05,
+ },
+ "blackswan": {"enabled": False},
+ "backtest_regime_filter": {"enabled": False},
+ }
+
+ @property
+ def risk_params(self):
+ return self._risk_params
+
+ @property
+ def trading(self):
+ return self._trading
+
+
+def _frame(close, *, open_=None, signals=None, start="2024-01-01"):
+ dates = pd.bdate_range(start, periods=len(close))
+ close = np.asarray(close, dtype=float)
+ open_ = close if open_ is None else np.asarray(open_, dtype=float)
+ df = pd.DataFrame(
+ {
+ "open": open_,
+ "high": np.maximum(open_, close),
+ "low": np.minimum(open_, close),
+ "close": close,
+ "volume": [1_000_000] * len(close),
+ "signal": signals or ["HOLD"] * len(close),
+ },
+ index=dates,
+ )
+ df["_avg_daily_volume"] = df["volume"]
+ return df
+
+
+# ─── 갭다운 청산은 전일부터 보유한 포지션에만 ──────────────────────
+
+
+def test_pending_buy_on_gap_down_open_is_not_sold_at_the_same_open():
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_GuardConfig(gap_enabled=True))
+ df = _frame(
+ [100.0, 96.0, 96.0],
+ open_=[100.0, 96.0, 96.0], # 1일차 시가 -4% 갭다운 (임계 -3%)
+ signals=["BUY", "HOLD", "HOLD"],
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ assert [t["action"] for t in result["trades"]] == ["BUY"]
+ buy = result["trades"][0]
+ assert buy["date"] == df.index[1]
+ assert buy["price"] == pytest.approx(96.0)
+ assert result["gap_down_exits"] == 0
+ # 매수일 장 마감 시점에도 포지션이 남아 있어야 한다.
+ day1 = result["equity_curve"].iloc[1]
+ assert day1["position_value"] == pytest.approx(buy["quantity"] * 96.0)
+
+
+def test_gap_down_still_exits_a_position_carried_overnight():
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_GuardConfig(gap_enabled=True))
+ df = _frame(
+ [100.0, 100.0, 96.0],
+ open_=[100.0, 100.0, 96.0],
+ signals=["BUY", "HOLD", "HOLD"],
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ assert [t["action"] for t in result["trades"]] == ["BUY", "GAP_DOWN"]
+ gap = result["trades"][1]
+ assert gap["date"] == df.index[2]
+ assert gap["price"] == pytest.approx(96.0)
+ assert result["gap_down_exits"] == 1
+
+
+# ─── 최소 보유 기간: 실전처럼 손실 방어 청산만 허용 ───────────────────
+
+
+def _actions_and_dates(result):
+ return [(t["action"], t["date"].strftime("%Y-%m-%d")) for t in result["trades"]]
+
+
+def test_min_hold_blocks_take_profit_and_partial_until_day_five():
+ """매수 다음날 +9%여도 5일 미만이면 부분·전량 익절하지 않는다 (실전 order_executor와 동일)."""
+ from backtest.backtester import Backtester
+
+ bt = Backtester(
+ _GuardConfig(gap_enabled=False, min_holding_days=5, partial_exit=True, take_profit=0.08)
+ )
+ # 2024-01-01(월) 신호 → 01-02 시가 매수. 01-03~01-05는 보유 1~3일, 01-08은 6일.
+ df = _frame(
+ [100.0, 100.0, 109.0, 109.0, 109.0, 109.0, 109.0],
+ signals=["BUY"] + ["HOLD"] * 6,
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ assert _actions_and_dates(result) == [
+ ("BUY", "2024-01-02"),
+ ("TAKE_PROFIT_PARTIAL", "2024-01-08"),
+ ("TAKE_PROFIT", "2024-01-09"),
+ ]
+
+
+def test_min_hold_blocks_max_hold_shorter_than_min_hold():
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_GuardConfig(gap_enabled=False, min_holding_days=5, max_holding_days=3))
+ df = _frame([100.0] * 8, signals=["BUY"] + ["HOLD"] * 7)
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ # 01-05(보유 3일)는 최소 보유 기간 안이라 만료 매도가 막히고 01-08(6일)에 청산된다.
+ assert _actions_and_dates(result) == [("BUY", "2024-01-02"), ("MAX_HOLD", "2024-01-08")]
+
+
+def test_min_hold_still_allows_stop_loss():
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_GuardConfig(gap_enabled=False, min_holding_days=5))
+ df = _frame(
+ [100.0, 100.0, 96.0],
+ open_=[100.0, 100.0, 100.0],
+ signals=["BUY", "HOLD", "HOLD"],
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ assert _actions_and_dates(result) == [("BUY", "2024-01-02"), ("STOP_LOSS", "2024-01-03")]
+
+
+def test_min_hold_still_allows_trailing_stop():
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_GuardConfig(gap_enabled=False, min_holding_days=5, trailing=True))
+ # 고점 104 대비 5% 하락선 98.8 이탈(98.5), 손절선 97은 미도달
+ df = _frame(
+ [100.0, 100.0, 104.0, 98.5],
+ open_=[100.0, 100.0, 104.0, 104.0],
+ signals=["BUY", "HOLD", "HOLD", "HOLD"],
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ assert _actions_and_dates(result) == [("BUY", "2024-01-02"), ("TRAILING_STOP", "2024-01-04")]
+
+
+def test_legacy_same_close_keeps_its_original_take_profit_rule():
+ """legacy_same_close는 과거 결과 재현 경로라 최소 보유 기간 익절 차단을 적용하지 않는다."""
+ from backtest.backtester import Backtester
+
+ bt = Backtester(
+ _GuardConfig(gap_enabled=False, min_holding_days=5, partial_exit=True, take_profit=0.08)
+ )
+ df = _frame(
+ [100.0, 100.0, 109.0, 109.0, 109.0, 109.0, 109.0],
+ signals=["BUY"] + ["HOLD"] * 6,
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0, execution_model="legacy_same_close")
+
+ assert _actions_and_dates(result) == [
+ ("BUY", "2024-01-01"),
+ ("TAKE_PROFIT_PARTIAL", "2024-01-03"),
+ ("TAKE_PROFIT", "2024-01-04"),
+ ]
diff --git a/tests/test_audit_backtest_validator.py b/tests/test_audit_backtest_validator.py
new file mode 100644
index 00000000..42aac006
--- /dev/null
+++ b/tests/test_audit_backtest_validator.py
@@ -0,0 +1,142 @@
+"""StrategyValidator 감사 회귀 테스트.
+
+- 코스피 상위 N 동일비중 벤치마크: 늦게 상장한 종목 하나 때문에 벤치마크 기간 전체가 잘리지 않는다
+ (outer 패널 + 첫 가격일 편입 매수·보유).
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+
+def _dates(n=320):
+ return pd.bdate_range("2023-01-02", periods=n)
+
+
+def _close_frame(dates, close):
+ close = np.asarray(close, dtype=float)
+ return pd.DataFrame(
+ {
+ "open": close,
+ "high": close * 1.01,
+ "low": close * 0.99,
+ "close": close,
+ "volume": np.full(len(close), 1_000_000.0),
+ },
+ index=dates,
+ )
+
+
+class _PanelCollector:
+ def __init__(self, frames: dict):
+ self.frames = frames
+
+ def fetch_korean_stock(self, symbol, start_date=None, end_date=None):
+ frame = self.frames[symbol]
+ start = pd.Timestamp(start_date) if start_date else frame.index.min()
+ end = pd.Timestamp(end_date) if end_date else frame.index.max()
+ return frame.loc[start:end].copy()
+
+
+def _top50_frames(dates, late_row=100):
+ rising = np.concatenate((np.linspace(100.0, 110.0, late_row), np.full(len(dates) - late_row, 110.0)))
+ return {
+ "AAA": _close_frame(dates, rising),
+ "BBB": _close_frame(dates, rising),
+ # 늦게 상장한 종목 (late_row부터 가격 존재)
+ "CCC": _close_frame(dates[late_row:], np.full(len(dates) - late_row, 50.0)),
+ }
+
+
+def test_equal_weight_panel_keeps_requested_start_and_marks_late_listing():
+ from backtest.strategy_validator import _build_equal_weight_panel
+
+ dates = _dates(20)
+ frames = {
+ "AAA": _close_frame(dates, np.full(20, 100.0)),
+ "LATE": _close_frame(dates[10:], np.full(10, 50.0)),
+ # 중간 결측(거래정지)과 조기 종료가 섞인 종목
+ "GAPPY": _close_frame(dates[:15].delete(5), np.arange(14, dtype=float) + 1.0),
+ }
+
+ panel = _build_equal_weight_panel(
+ _PanelCollector(frames), list(frames), str(dates[0].date()), str(dates[-1].date())
+ )
+
+ assert panel.index[0] == dates[0]
+ assert panel.index[-1] == dates[-1]
+ assert panel["LATE"].iloc[:10].isna().all()
+ assert panel["LATE"].iloc[10:].notna().all()
+ # 중간 결측은 직전 종가로 채우고, 데이터 종료 이후는 NaN으로 둔다.
+ assert panel["GAPPY"].iloc[5] == panel["GAPPY"].iloc[4]
+ assert panel["GAPPY"].iloc[15:].isna().all()
+
+
+def test_staggered_equal_weight_equity_enters_late_name_at_its_first_price():
+ from backtest.strategy_validator import _staggered_equal_weight_equity
+
+ dates = _dates(6)
+ panel = pd.DataFrame(
+ {
+ "A": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "B": [100.0, 200.0, 200.0, 200.0, 200.0, 200.0],
+ "C": [np.nan, np.nan, np.nan, 50.0, 50.0, 100.0],
+ },
+ index=dates,
+ )
+
+ equity = _staggered_equal_weight_equity(panel, 1_000.0)
+
+ assert equity.index[0] == dates[0]
+ # A·B 반반 매수 → B 두 배: 500 + 1,000 = 1,500
+ assert equity.iloc[1] == pytest.approx(1_500.0)
+ # C 편입일: 평가액 1,500의 1/3(500)을 C에 배정, A·B는 2/3로 축소 → 평가액 그대로
+ assert equity.iloc[3] == pytest.approx(1_500.0)
+ # C 두 배: 1,000(A·B) + 1,000(C) = 2,000 — 동일 비중 재조정 없이 매수·보유
+ assert equity.iloc[5] == pytest.approx(2_000.0)
+
+
+def test_equal_weight_metrics_cover_whole_period_despite_late_listing():
+ from backtest.strategy_validator import _equal_weight_buy_and_hold_metrics
+
+ dates = _dates(320)
+ frames = _top50_frames(dates)
+
+ metrics = _equal_weight_buy_and_hold_metrics(
+ _PanelCollector(frames), list(frames), str(dates[0].date()), str(dates[-1].date()), 1_000_000.0
+ )
+
+ # 예전 inner 조인은 CCC 상장일부터만 재서 AAA·BBB의 초기 +10%를 놓쳤다(0%).
+ assert metrics["total_return"] == pytest.approx(10.0, abs=0.01)
+
+
+def test_validator_top50_benchmark_spans_strategy_period(monkeypatch, tmp_path):
+ import backtest.strategy_validator as sv
+
+ dates = _dates(320)
+ rng = np.random.default_rng(12)
+ strategy_close = 50_000 * np.cumprod(1 + rng.normal(0.0003, 0.015, len(dates)))
+ frames = {
+ "005930": _close_frame(dates, strategy_close),
+ "KS11": _close_frame(dates, strategy_close * 0.05),
+ **_top50_frames(dates),
+ }
+ monkeypatch.setattr(sv, "DataCollector", lambda: _PanelCollector(frames))
+ monkeypatch.setattr(sv, "_get_kospi_top_n_symbols", lambda *a, **k: ["AAA", "BBB", "CCC"])
+
+ validator = sv.StrategyValidator(output_dir=str(tmp_path))
+ result = validator.run(
+ symbol="005930",
+ strategy_name="scoring",
+ start_date=str(dates[0].date()),
+ end_date=str(dates[-1].date()),
+ )
+
+ top50 = result["benchmark_top50"]
+ assert top50["coverage"]["at_start"] == 2
+ assert top50["coverage"]["late_entries"] == {"CCC": str(dates[100].date())}
+ assert top50["full"]["total_return"] == pytest.approx(10.0, abs=0.01)
+ # 인샘플(0~223행)도 CCC 상장일로 잘리지 않고 처음부터 잰다.
+ assert top50["in_sample"]["total_return"] == pytest.approx(10.0, abs=0.01)
+ assert top50["out_sample"]["total_return"] == pytest.approx(0.0, abs=0.01)
+ assert "편입 범위" in validator.render_text_report(result)
diff --git a/tests/test_audit_backtest_warmup.py b/tests/test_audit_backtest_warmup.py
new file mode 100644
index 00000000..8822e13e
--- /dev/null
+++ b/tests/test_audit_backtest_warmup.py
@@ -0,0 +1,213 @@
+"""워크포워드·OOS 지표 워밍업 감사 회귀 테스트.
+
+Backtester.run(trade_start_date=...)는 PortfolioBacktester와 같은 규약으로 앞 구간을 지표
+워밍업으로만 쓰고, 거래·자본 곡선·지표는 trade_start_date부터 잰다. 검증기 OOS·워크포워드와
+최적화기 OOS가 이 경로를 쓴다.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+
+class _WarmupConfig:
+ settings = {}
+ strategies = {}
+
+ def __init__(self, *, dynamic_slippage=False):
+ self._trading = {"skip_earnings_days": 0}
+ self._risk_params = {
+ "transaction_costs": {
+ "commission_rate": 0.00015,
+ "tax_rate": 0.002,
+ "slippage": 0.001,
+ "slippage_ticks": 0,
+ "dynamic_slippage": {
+ "enabled": dynamic_slippage,
+ "warn_at_volume_ratio": 0.01,
+ "warn_slippage_multiplier": 2.0,
+ "critical_at_volume_ratio": 0.03,
+ "critical_slippage_multiplier": 4.0,
+ },
+ },
+ "stop_loss": {"type": "fixed", "fixed_rate": 0.50},
+ "take_profit": {"fixed_rate": 0.50, "partial_exit": False},
+ "trailing_stop": {"enabled": False},
+ "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 100_000},
+ "diversification": {"max_position_ratio": 0.20, "max_investment_ratio": 0.70},
+ "position_limits": {"min_holding_days": 0, "max_holding_days": 0, "max_monthly_roundtrips": 0},
+ "liquidity_filter": {"backtest_max_participation_rate": 1.0},
+ "gap_risk": {"enabled": False},
+ "blackswan": {"enabled": False},
+ "backtest_regime_filter": {"enabled": False},
+ }
+
+ @property
+ def risk_params(self):
+ return self._risk_params
+
+ @property
+ def trading(self):
+ return self._trading
+
+
+class _SmaCrossStrategy:
+ """종가가 N일 단순이동평균 위면 BUY, 아래면 SELL (과거 데이터만 쓰는 인과적 신호)."""
+
+ def __init__(self, window: int, sell_below: bool = False):
+ self.window = window
+ self.sell_below = sell_below
+
+ def analyze(self, df):
+ out = df.copy()
+ sma = out["close"].rolling(self.window, min_periods=self.window).mean()
+ out["signal"] = "HOLD"
+ out.loc[out["close"] > sma, "signal"] = "BUY"
+ if self.sell_below:
+ out.loc[out["close"] < sma, "signal"] = "SELL"
+ return out
+
+
+def _ohlcv(close, volume=None, start="2022-01-03"):
+ close = np.asarray(close, dtype=float)
+ dates = pd.bdate_range(start, periods=len(close))
+ volume = np.full(len(close), 1_000_000.0) if volume is None else np.asarray(volume, dtype=float)
+ return pd.DataFrame(
+ {
+ "open": close,
+ "high": close * 1.01,
+ "low": close * 0.99,
+ "close": close,
+ "volume": volume,
+ },
+ index=dates,
+ )
+
+
+def _backtester(strategy, **config_kwargs):
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_WarmupConfig(**config_kwargs))
+ bt._get_strategy = lambda _name: strategy
+ return bt
+
+
+def test_trade_start_date_warms_indicators_before_the_test_window():
+ """60일 이동평균 전략: 테스트 구간만 넣으면 60행 뒤에야 첫 매수, 워밍업을 넣으면 첫날 매수."""
+ df = _ohlcv(100.0 + np.arange(200, dtype=float)) # 꾸준한 상승 → 워밍업만 되면 항상 BUY
+ test_start = df.index[120]
+ strategy = _SmaCrossStrategy(window=60)
+
+ cold = _backtester(strategy).run(df.iloc[120:].copy(), strategy_name="sma", strict_lookahead=True)
+ warm = _backtester(strategy).run(
+ df.copy(), strategy_name="sma", strict_lookahead=True, trade_start_date=test_start
+ )
+
+ cold_first_buy = next(t for t in cold["trades"] if t["action"] == "BUY")
+ warm_first_buy = next(t for t in warm["trades"] if t["action"] == "BUY")
+ assert cold_first_buy["date"] == df.index[180] # 창 60번째 행 신호 → 다음 날 시가
+ assert warm_first_buy["date"] == test_start # 워밍업 마지막 날 신호 → 첫날 시가
+ assert warm_first_buy["signal_date"] == df.index[119]
+
+ # 자본 곡선·기간·지표는 평가 구간만
+ equity = warm["equity_curve"]
+ assert equity["date"].iloc[0] == test_start
+ assert len(equity) == 80
+ assert warm["warmup_rows"] == 120
+ assert warm["period"].startswith(str(test_start))
+ assert all(t["date"] >= test_start for t in warm["trades"])
+
+
+def test_skipped_warmup_rows_do_not_change_strict_results():
+ """strict 모드에서 분석을 건너뛴 워밍업 행도 원본 값(거래량 등)을 유지해 relaxed 실행과 같다."""
+ # 130행까지 상승 후 하락 → 첫 거래일 매수, 하락 구간에서 매도
+ close = np.concatenate((100.0 + np.arange(130) * 0.5, 164.5 - np.arange(1, 31) * 2.0))
+ # 워밍업 거래량은 크고 평가 구간은 작다. 워밍업 행의 거래량이 비면 20일 평균 거래량이
+ # 급감해 동적 슬리피지 배수가 달라지고 체결가가 relaxed 실행과 어긋난다.
+ # (1% 손실 규칙으로 주문은 13주 안팎 → 거래량 400주면 참여율 3%를 넘는다)
+ volume = np.where(np.arange(160) < 99, 1_000_000.0, 400.0)
+ df = _ohlcv(close, volume)
+ strategy = _SmaCrossStrategy(window=20, sell_below=True)
+ kwargs = dict(strategy_name="sma", trade_start_date=df.index[100])
+
+ strict = _backtester(strategy, dynamic_slippage=True).run(df.copy(), strict_lookahead=True, **kwargs)
+ relaxed = _backtester(strategy, dynamic_slippage=True).run(df.copy(), strict_lookahead=False, **kwargs)
+
+ assert [t["action"] for t in strict["trades"]][:2] == ["BUY", "SELL"]
+ assert strict["trades"][0]["date"] == df.index[100]
+ assert strict["trades"] == relaxed["trades"]
+ pd.testing.assert_frame_equal(strict["equity_curve"], relaxed["equity_curve"])
+
+
+def test_trade_start_date_after_last_row_is_an_error():
+ df = _ohlcv(np.full(30, 100.0))
+ bt = _backtester(_SmaCrossStrategy(window=5))
+
+ with pytest.raises(ValueError):
+ bt.run(df, strategy_name="sma", trade_start_date=df.index[-1] + pd.Timedelta(days=30))
+
+
+# ─── 검증기 OOS ────────────────────────────────────────────────
+
+
+def test_validator_out_of_sample_run_uses_in_sample_as_warmup(monkeypatch, tmp_path):
+ import backtest.strategy_validator as sv
+
+ rng = np.random.default_rng(5)
+ df = _ohlcv(50_000 * np.cumprod(1 + rng.normal(0.0003, 0.015, 160)), rng.integers(500_000, 2_000_000, 160))
+
+ class FakeCollector:
+ def fetch_korean_stock(self, symbol, start_date=None, end_date=None):
+ return df.copy()
+
+ monkeypatch.setattr(sv, "DataCollector", FakeCollector)
+ validator = sv.StrategyValidator(output_dir=str(tmp_path))
+ calls = []
+ original_run = validator.backtester.run
+
+ def spy_run(frame, **kwargs):
+ calls.append((len(frame), kwargs.get("trade_start_date")))
+ return original_run(frame, **kwargs)
+
+ monkeypatch.setattr(validator.backtester, "run", spy_run)
+
+ result = validator.run(symbol="005930", strategy_name="scoring", use_benchmark_top50=False)
+
+ split_idx = 112 # max(60, int(160 × 0.7))
+ assert (160, df.index[split_idx]) in calls # OOS: 전체를 넣고 split부터 거래
+ assert (split_idx, None) in calls # 인샘플은 그대로
+ oos_equity = result["out_sample"]["equity_curve"]
+ assert oos_equity["date"].iloc[0] == df.index[split_idx]
+ assert len(oos_equity) == 160 - split_idx
+
+
+# ─── 최적화기 OOS ──────────────────────────────────────────────
+
+
+def test_optimizer_out_of_sample_runs_use_train_span_as_warmup(monkeypatch):
+ import backtest.param_optimizer as po
+
+ df = _ohlcv(100.0 + np.arange(200, dtype=float))
+ calls = []
+
+ def fake_run_single(frame, strategy_name, params, config, strict, capital, trade_start_date=None):
+ calls.append((len(frame), trade_start_date))
+ return {"sharpe_ratio": 2.0, "total_trades": 5, "total_return": 1.0, "max_drawdown": -1.0}
+
+ monkeypatch.setattr(po, "_run_single", fake_run_single)
+ config = _WarmupConfig()
+ train_end = 140 # int(200 × 0.7)
+
+ po.grid_search(df, "scoring", search_space={"buy_threshold": [3]}, config=config, initial_capital=100_000)
+ assert calls == [(train_end, None), (200, df.index[train_end])]
+
+ calls.clear()
+ result = po.grid_search_scoring_weights(
+ df,
+ weight_search_space={"w_macd": [1], "w_bollinger": [1], "w_volume": [1]},
+ threshold_pairs=[(3, -3)],
+ config=config,
+ initial_capital=100_000,
+ )
+ assert result is not None
+ assert calls == [(train_end, None), (200, df.index[train_end])]
diff --git a/tests/test_audit_cycle.py b/tests/test_audit_cycle.py
new file mode 100644
index 00000000..fc06b4e4
--- /dev/null
+++ b/tests/test_audit_cycle.py
@@ -0,0 +1,366 @@
+"""일일 바스켓 사이클 회귀 테스트 (2026-09-23 점검).
+
+- 휴장일 실행은 매매 없이 스냅샷만 남긴다(추석 평일에도 일일 태스크는 돈다).
+- 결측 보충은 실제로 호출돼야 한다 — 8/27 도입 이래 정의되지 않은 이름(baskets_cfg)
+ 때문에 매 사이클 실패했지만 경고 로그로만 남아 한 달간 아무도 몰랐다. 함수가 아니라
+ 연결을 테스트한다.
+- 보충은 계정이 생기기 전 날을 채우지 않고, 입금 경계를 스냅샷을 실제로 찍은 시각으로 잡는다.
+"""
+
+from datetime import date, datetime, time
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+
+def _args(dry_run=False, force_rebalance=False):
+ return SimpleNamespace(
+ basket="kr_test", dry_run=dry_run, confirm_live=False,
+ force_rebalance=force_rebalance,
+ )
+
+
+@pytest.fixture
+def cycle(monkeypatch):
+ """사이클 협력자를 모킹하고 (fake_rebalancer, 호출 순서 기록)을 돌려준다."""
+ from config.config_loader import Config
+
+ Config._instance = None
+ order = []
+ fake_rb = MagicMock()
+ fake_rb.portfolio_mgr.initial_capital = 300_000
+ fake_rb.get_status_report.side_effect = lambda: order.append("status") or "status"
+ fake_rb.should_rebalance.return_value = (True, "드리프트")
+ fake_rb.plan_rebalance.return_value = []
+ fake_rb.plan_risk_exits.return_value = []
+ monkeypatch.setattr(
+ "core.basket_rebalancer.BasketRebalancer", MagicMock(return_value=fake_rb)
+ )
+ monkeypatch.setattr("core.notifier.Notifier", MagicMock())
+ monkeypatch.setattr("database.repositories.get_trade_history", lambda **kw: [])
+ calls = {}
+
+ def _fake_backfill(config, account_key, initial_capital, since, until, mode="paper"):
+ order.append("backfill")
+ calls["backfill"] = dict(
+ account_key=account_key, initial_capital=initial_capital,
+ since=since, until=until, mode=mode,
+ )
+ return []
+
+ monkeypatch.setattr("core.snapshot_backfill.backfill_account", _fake_backfill)
+ return fake_rb, order, calls
+
+
+# ------------------------------------------------------------------ 휴장일
+
+def test_holiday_run_skips_trading_but_saves_snapshot(cycle, monkeypatch):
+ import main
+
+ fake_rb, _, _ = cycle
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: True)
+
+ main.run_rebalance(_args())
+
+ fake_rb.plan_risk_exits.assert_not_called()
+ fake_rb.should_rebalance.assert_not_called()
+ fake_rb.plan_rebalance.assert_not_called()
+ fake_rb.execute.assert_not_called()
+ fake_rb.save_daily_nav_snapshot.assert_called_once()
+
+
+def test_holiday_run_can_be_forced(cycle, monkeypatch):
+ import main
+
+ fake_rb, _, _ = cycle
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: True)
+
+ main.run_rebalance(_args(force_rebalance=True))
+
+ fake_rb.plan_risk_exits.assert_called_once()
+ fake_rb.plan_rebalance.assert_called_once()
+
+
+def test_trading_day_runs_normal_path(cycle, monkeypatch):
+ import main
+
+ fake_rb, _, _ = cycle
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False)
+
+ main.run_rebalance(_args())
+
+ fake_rb.plan_risk_exits.assert_called_once()
+ fake_rb.plan_rebalance.assert_called_once()
+
+
+def test_market_closed_helper_uses_calendar():
+ import main
+ from config.config_loader import Config
+
+ Config._instance = None
+ cfg = Config.get()
+ assert main._market_closed_today(cfg, datetime(2026, 9, 24, 10, 7)) is True # 추석
+ assert main._market_closed_today(cfg, datetime(2026, 9, 28, 10, 7)) is False # 대체 아님
+ assert main._market_closed_today(cfg, datetime(2026, 9, 26, 10, 7)) is True # 토요일
+
+
+# ------------------------------------------------------------------ 결측 보충 연결
+
+def test_backfill_is_wired_with_resolved_capital_before_status(cycle, monkeypatch):
+ import main
+
+ _, order, calls = cycle
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False)
+
+ main.run_rebalance(_args())
+
+ from datetime import timedelta
+ from zoneinfo import ZoneInfo
+
+ assert "backfill" in calls, "결측 보충이 호출되지 않았다"
+ assert calls["backfill"]["initial_capital"] == 300_000.0
+ assert calls["backfill"]["account_key"] == "basket_rebalance:kr_test"
+ kst_today = datetime.now(ZoneInfo("Asia/Seoul")).date()
+ assert calls["backfill"]["until"] == kst_today - timedelta(days=1)
+ # 오버레이 판단(상태 보고에서 계산·캐시)보다 먼저 보충돼야 한다
+ assert order.index("backfill") < order.index("status")
+
+
+def test_backfill_failure_is_recorded_as_event(cycle, monkeypatch):
+ import main
+ import core.cycle_observability as co
+
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False)
+
+ def _boom(*a, **k):
+ raise RuntimeError("시세 조회 실패")
+
+ monkeypatch.setattr("core.snapshot_backfill.backfill_account", _boom)
+ recorded = []
+ monkeypatch.setattr(
+ co, "record_event_once_per_day",
+ lambda event_type, message, **kw: recorded.append((event_type, kw)) or True,
+ )
+
+ main.run_rebalance(_args())
+
+ assert [e for e, _ in recorded] == ["SNAPSHOT_BACKFILL_FAILED"]
+ assert recorded[0][1]["strategy"] == "basket_rebalance:kr_test"
+
+
+def test_backfill_not_called_on_dry_run(cycle, monkeypatch):
+ import main
+
+ _, _, calls = cycle
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False)
+ main.run_rebalance(_args(dry_run=True))
+ assert "backfill" not in calls
+
+
+def test_backfill_not_called_in_live_mode(cycle, monkeypatch):
+ """live 기록은 증권사 잔고를 따라야 한다 — 로컬 원장·중간값 추정으로 채우지 않는다."""
+ import main
+ from config.config_loader import Config
+
+ fake_rb, _, calls = cycle
+ fake_rb.portfolio_mgr.sync_with_broker.return_value = {"ok": True}
+ config = Config.get()
+ monkeypatch.setitem(config.trading, "mode", "live")
+ monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False)
+ monkeypatch.setattr(main, "_require_live_operator_confirmation", lambda *a, **k: None)
+ monkeypatch.setattr(main, "_check_live_readiness_gate", lambda *a, **k: [])
+ monkeypatch.setattr(
+ "core.basket_rebalancer.check_basket_account_isolation", lambda *a, **k: [],
+ )
+
+ main.run_rebalance(_args())
+
+ assert "backfill" not in calls
+
+
+# ------------------------------------------------------------------ 보충 범위·입금 경계
+
+def _add_trade(key, day, symbol="069500", qty=1, price=100_000.0):
+ from database.models import TradeHistory, get_session, init_database
+
+ init_database()
+ s = get_session()
+ try:
+ s.add(TradeHistory(
+ account_key=key, symbol=symbol, action="BUY", price=price, quantity=qty,
+ total_amount=price * qty, mode="paper",
+ executed_at=datetime.combine(day, time(10, 7)),
+ ))
+ s.commit()
+ finally:
+ s.close()
+
+
+def test_backfill_does_not_fill_days_before_account_existed(monkeypatch):
+ """계정 첫 활동일(7/10) 이전의 평일은 '결측'이 아니다."""
+ import core.snapshot_backfill as sb
+ from database.repositories import save_portfolio_snapshot
+
+ key = "basket_rebalance:t_preinception"
+ _add_trade(key, date(2026, 7, 10))
+ for d in (date(2026, 7, 10), date(2026, 7, 13), date(2026, 7, 15)):
+ save_portfolio_snapshot(
+ total_value=300_000, cash=200_000, invested=100_000,
+ account_key=key, snapshot_date=datetime.combine(d, time()), mode="paper",
+ )
+ monkeypatch.setattr(sb, "historical_mark", lambda symbol, day: 100_000.0)
+
+ filled = sb.backfill_account(
+ None, key, 300_000, date(2026, 7, 1), date(2026, 7, 15), dry_run=True,
+ )
+
+ assert [f["date"] for f in filled] == [date(2026, 7, 14)]
+
+
+def test_backfill_returns_nothing_for_account_without_activity():
+ import core.snapshot_backfill as sb
+
+ assert sb.backfill_account(
+ None, "basket_rebalance:t_no_activity", 300_000,
+ date(2026, 9, 1), date(2026, 9, 22), dry_run=True,
+ ) == []
+
+
+def test_deposit_after_previous_snapshot_is_not_booked_as_return():
+ """금 10:07 스냅샷 이후 17:19 입금 → 월요일 복원분의 수익률은 0이어야 한다."""
+ import core.snapshot_backfill as sb
+ from database.repositories import record_cash_flow, save_portfolio_snapshot
+
+ key = "basket_rebalance:t_flow_after"
+ fri, mon = date(2026, 9, 18), date(2026, 9, 21)
+ save_portfolio_snapshot(
+ total_value=300_000, cash=300_000, invested=0, cumulative_return=0.0,
+ account_key=key, snapshot_date=datetime.combine(fri, time()), mode="paper",
+ measured_at=datetime.combine(fri, time(10, 7)),
+ )
+ record_cash_flow(100_000, account_key=key, occurred_at=datetime.combine(fri, time(17, 19)))
+
+ snap = sb.reconstruct_snapshot(None, key, mon, 300_000)
+
+ assert snap["total_value"] == 400_000
+ assert snap["cumulative_return"] == pytest.approx(0.0, abs=1e-9)
+ assert snap["daily_return"] == pytest.approx(0.0, abs=1e-9)
+ assert snap["mdd"] == pytest.approx(0.0, abs=1e-9)
+
+
+def test_reconstructed_row_is_measured_at_end_of_its_day():
+ """복원 행을 찍은 시각을 저장한 시각(다음 날)으로 두면, 다음 날 아침 입금이 빠진다."""
+ import core.snapshot_backfill as sb
+ from database.models import PortfolioSnapshot, get_session
+ from database.repositories import (
+ get_cash_flow_total_between,
+ record_cash_flow,
+ save_portfolio_snapshot,
+ )
+
+ key = "basket_rebalance:t_flow_before_cycle"
+ fri, mon, tue = date(2026, 9, 18), date(2026, 9, 21), date(2026, 9, 22)
+ save_portfolio_snapshot(
+ total_value=300_000, cash=300_000, invested=0, cumulative_return=0.0,
+ account_key=key, snapshot_date=datetime.combine(fri, time()), mode="paper",
+ measured_at=datetime.combine(fri, time(10, 7)),
+ )
+ record_cash_flow(100_000, account_key=key, occurred_at=datetime.combine(tue, time(8, 0)))
+
+ filled = sb.backfill_account(None, key, 300_000, mon, mon)
+ assert [f["date"] for f in filled] == [mon]
+
+ s = get_session()
+ try:
+ row = s.query(PortfolioSnapshot).filter(
+ PortfolioSnapshot.account_key == key,
+ PortfolioSnapshot.date == datetime.combine(mon, time()),
+ ).one()
+ measured = row.created_at
+ assert row.reconstructed is True
+ assert row.cumulative_return == pytest.approx(0.0, abs=1e-9)
+ finally:
+ s.close()
+ assert measured.date() == mon
+ # 화요일 실행의 입금 구간(월 복원 행 시각, 화 10:07]에 화 08:00 입금이 들어가야 한다
+ assert get_cash_flow_total_between(
+ key, measured, datetime.combine(tue, time(10, 7)),
+ ) == pytest.approx(100_000)
+
+
+# ------------------------------------------------------------------ 중복 억제 이벤트
+
+def test_event_once_per_day_dedupes_same_cause():
+ from core.cycle_observability import record_event_once_per_day
+
+ kw = dict(strategy="basket_rebalance:t_dedupe", mode="paper")
+ assert record_event_once_per_day("ORDER_REJECTED", "거부 A", symbol="005930",
+ dedupe_key="사유 A", **kw) is True
+ assert record_event_once_per_day("ORDER_REJECTED", "거부 A", symbol="005930",
+ dedupe_key="사유 A", **kw) is False
+ # 원인이 다르면 따로 남는다
+ assert record_event_once_per_day("ORDER_REJECTED", "거부 B", symbol="005930",
+ dedupe_key="사유 B", **kw) is True
+ assert record_event_once_per_day("ORDER_REJECTED", "거부 A", symbol="035720",
+ dedupe_key="사유 A", **kw) is True
+
+
+# ------------------------------------------------------------------ 커버리지 게이트
+
+def test_coverage_gate_uses_measured_days_not_reconstructed():
+ """보충이 매번 100%로 메우면 게이트가 '사이클이 실제로 돌았는가'를 못 본다."""
+ from core.basket_evaluation import evaluate_basket_paper_operation
+
+ r = evaluate_basket_paper_operation(
+ operation_start=date(2026, 6, 10), today=date(2026, 9, 10),
+ trading_days_total=60, snapshot_days=60, reconstructed_days=6,
+ pending_failed_orders=0, total_costs=0, initial_capital=10_000_000,
+ )
+ assert r["snapshot_coverage"] == pytest.approx(1.0) # 표시용 전체 커버리지는 그대로
+ assert r["measured_coverage"] == pytest.approx(0.9)
+ assert r["verdict"] == "FAIL_REVIEW"
+ assert any("제때 남긴 스냅샷" in i and "나중에 채운 6일 제외" in i for i in r["issues"])
+
+
+def test_coverage_gate_passes_with_few_reconstructed_days():
+ from core.basket_evaluation import evaluate_basket_paper_operation
+
+ r = evaluate_basket_paper_operation(
+ operation_start=date(2026, 6, 10), today=date(2026, 9, 10),
+ trading_days_total=60, snapshot_days=60, reconstructed_days=2,
+ pending_failed_orders=0, total_costs=0, initial_capital=10_000_000,
+ )
+ assert r["verdict"] == "PASS_CANDIDATE"
+
+
+# ------------------------------------------------------------------ 추정 기록과 고점
+
+def test_reconstructed_day_does_not_set_the_peak():
+ """나중에 채운 추정치(시가·종가 중간값)가 고점이 되면 이후 낙폭이 계속 부풀어 보인다."""
+ from database.models import init_database
+ from database.repositories import get_max_cumulative_return, save_portfolio_snapshot
+
+ init_database()
+ key = "basket_rebalance:peak_recon"
+ for day, cum, recon in ((date(2026, 9, 1), 2.0, False),
+ (date(2026, 9, 2), 5.0, True), # 추정치가 가장 높다
+ (date(2026, 9, 3), 3.0, False)):
+ save_portfolio_snapshot(
+ total_value=100.0 + cum, cash=0.0, invested=100.0, cumulative_return=cum,
+ mdd=0.0, position_count=1, account_key=key, mode="paper",
+ snapshot_date=datetime(day.year, day.month, day.day), reconstructed=recon,
+ )
+
+ assert get_max_cumulative_return(account_key=key, mode="paper") == pytest.approx(3.0)
+
+
+def test_overlay_drawdown_skips_reconstructed_peaks():
+ from core.risk_overlays import drawdown_from_cumulative_returns
+
+ series = [0.0, 10.0, 4.5]
+ assert drawdown_from_cumulative_returns(series) == pytest.approx(1.045 / 1.10 - 1)
+ # 10%가 추정 기록이면 고점은 시작점(1.0)이고 지금은 고점 위라 낙폭 0
+ assert drawdown_from_cumulative_returns(series, [True, False, True]) == pytest.approx(0.0)
+ # 마지막 날이 추정 기록이어도 현재 수준으로는 쓴다
+ assert drawdown_from_cumulative_returns([0.0, 10.0, -2.0], [True, True, False]) == pytest.approx(0.98 / 1.10 - 1)
diff --git a/tests/test_audit_dashboard.py b/tests/test_audit_dashboard.py
new file mode 100644
index 00000000..5666a4dc
--- /dev/null
+++ b/tests/test_audit_dashboard.py
@@ -0,0 +1,233 @@
+"""대시보드 회귀 테스트 (2026-09-23 점검).
+
+- 기록 공백은 KRX 거래일로 잰다(달력 날짜·스케줄러 루프 나이 규칙은 밤·주말·휴장일마다
+ 거짓 경보를 내고, 평일 이틀 공백은 '정상'으로 보였다).
+- 원금 대비 손익은 스냅샷 시점 원금으로 잰다(적립 직후 가짜 손실 방지).
+- '이전 기본 계좌'는 기본 계정만 DB에서 읽는다(전 계정 합산 가짜 계좌 금지).
+- 적립 기록은 로컬 대시보드에서, 운용 중인 계좌에만.
+"""
+
+from datetime import date, datetime
+from unittest.mock import patch
+
+import pytest
+
+from database.models import init_database
+
+
+# ------------------------------------------------------------ 기록 공백
+
+def test_freshness_counts_trading_days_only():
+ from monitoring.web_dashboard import _snapshot_freshness
+
+ # 추석 연휴 뒤 첫 거래일(9/28 월) 11:00 — 마지막 기록 9/23이면 9/28 하루만 빠진 것
+ out = _snapshot_freshness(date(2026, 9, 23), now=datetime(2026, 9, 28, 11, 0))
+ assert out == {"expected_snapshot_date": "2026-09-28", "missed_trading_days": 1}
+
+
+def test_freshness_does_not_expect_today_before_cycle_time():
+ from monitoring.web_dashboard import _snapshot_freshness
+
+ out = _snapshot_freshness(date(2026, 9, 23), now=datetime(2026, 9, 28, 9, 0))
+ assert out["expected_snapshot_date"] == "2026-09-23"
+ assert out["missed_trading_days"] == 0
+
+
+def test_freshness_quiet_over_weekend_and_holidays():
+ from monitoring.web_dashboard import _snapshot_freshness
+
+ for now in (datetime(2026, 9, 24, 12, 0), datetime(2026, 9, 26, 12, 0),
+ datetime(2026, 9, 27, 23, 0)):
+ assert _snapshot_freshness(date(2026, 9, 23), now=now)["missed_trading_days"] == 0
+
+
+def test_freshness_flags_two_dead_weekdays():
+ from monitoring.web_dashboard import _snapshot_freshness
+
+ out = _snapshot_freshness(date(2026, 9, 16), now=datetime(2026, 9, 18, 11, 0))
+ assert out["missed_trading_days"] == 2
+
+
+# ------------------------------------------------------------ 스케줄러 감시
+
+def test_unused_scheduler_is_never_stale():
+ from monitoring.web_dashboard import _scheduler_freshness
+
+ out = _scheduler_freshness({}, now=datetime(2026, 9, 22, 11, 0))
+ assert out == {"scheduler_in_use": False, "scheduler_stale": False}
+
+
+def test_scheduler_stale_only_during_market_hours():
+ from monitoring.web_dashboard import _scheduler_freshness
+
+ runtime = {"loop_metrics": {"last_success": "2026-09-22T09:10:00"}}
+ assert _scheduler_freshness(runtime, now=datetime(2026, 9, 22, 11, 0))["scheduler_stale"] is True
+ assert _scheduler_freshness(runtime, now=datetime(2026, 9, 22, 20, 0))["scheduler_stale"] is False
+
+
+# ------------------------------------------------------------ 시장 국면 표시
+
+def test_disabled_regime_filter_is_not_shown_as_bullish(monkeypatch):
+ from monitoring import web_dashboard as wd
+
+ monkeypatch.setattr("core.market_regime.resolve_market_regime_config",
+ lambda config, **kw: {"enabled": False})
+ out = wd.get_runtime_json()
+ assert out["market_regime"]["regime"] == "disabled"
+
+
+# ------------------------------------------------------------ 이전 기본 계좌
+
+def test_legacy_panel_is_empty_when_default_account_has_no_records():
+ from monitoring import web_dashboard as wd
+
+ init_database()
+ with patch("database.repositories.get_all_positions", return_value=[]), \
+ patch("database.repositories.get_trade_history", return_value=[]):
+ out = wd.get_portfolio_json()
+ assert out["empty"] is True
+
+
+# ------------------------------------------------------------ 스냅샷 시점 원금
+
+def test_principal_is_measured_at_snapshot_time():
+ """스냅샷(9/22 10:07) 뒤 적립 10만 원은 아직 평가액에 없다 — 손익 비교에서 뺀다."""
+ from monitoring import web_dashboard as wd
+ from database.repositories import record_cash_flow, save_portfolio_snapshot
+
+ init_database()
+ name = "kr_pocket_pending"
+ key = f"basket_rebalance:{name}"
+ cfg = {name: {"enabled": True, "initial_capital": 300_000,
+ "holdings": {"069500": 0.5, "357870": 0.5},
+ "target_stock_weight": 0.95}}
+ save_portfolio_snapshot(
+ total_value=390_000, cash=100_000, invested=290_000, account_key=key,
+ snapshot_date=datetime(2026, 9, 22), mode="paper",
+ measured_at=datetime(2026, 9, 22, 10, 7),
+ )
+ record_cash_flow(100_000, account_key=key, occurred_at=datetime(2026, 9, 22, 18, 0))
+ with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config", return_value=cfg):
+ out = wd.get_baskets_json()
+ b = next(x for x in out["baskets"] if x["basket"] == name)
+ assert b["principal"] == pytest.approx(400_000)
+ assert b["principal_at_snapshot"] == pytest.approx(300_000)
+ assert b["pending_deposits"] == pytest.approx(100_000)
+ assert b["profit_vs_principal"] == pytest.approx(90_000) # -1만이 아니라 +9만
+
+
+# ------------------------------------------------------------ 적립 기록 보호
+
+def _deposit(host=None, origin=None, basket="kr_pocket_host", enabled=True):
+ import asyncio
+ from aiohttp.test_utils import TestClient, TestServer
+ from monitoring import web_dashboard as wd
+
+ init_database()
+ cfg = {basket: {"enabled": enabled, "initial_capital": 300_000,
+ "holdings": {"069500": 1.0}}}
+ result = {}
+
+ async def run():
+ with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config",
+ return_value=cfg):
+ client = TestClient(TestServer(wd.create_app()))
+ await client.start_server()
+ try:
+ headers = {"X-Requested-With": "quant-dashboard",
+ "Idempotency-Key": f"test-host-check-{basket}-0001"}
+ if host:
+ headers["Host"] = host
+ if origin:
+ headers["Origin"] = origin
+ res = await client.post("/api/deposit", json={"basket": basket, "amount": 1000},
+ headers=headers)
+ result["status"] = res.status
+ finally:
+ await client.close()
+
+ asyncio.run(run())
+ return result["status"]
+
+
+def test_deposit_rejects_non_loopback_host():
+ """DNS 리바인딩: 외부 도메인이 127.0.0.1을 가리켜도 Host 헤더는 그 도메인이다."""
+ assert _deposit(host="evil.example:8080") == 403
+
+
+def test_deposit_rejects_cross_origin():
+ assert _deposit(origin="https://evil.example") == 403
+
+
+def test_deposit_rejects_disabled_basket():
+ assert _deposit(basket="kr_pocket_off", enabled=False) == 400
+
+
+def test_deposit_accepts_local_request():
+ assert _deposit(origin="http://127.0.0.1:8080", basket="kr_pocket_ok") == 200
+
+
+# ------------------------------------------------------------ 경보 채널
+
+class _Resp:
+ def __init__(self, status, body=None, text=""):
+ self.status_code = status
+ self._body = body or {}
+ self.text = text
+
+ def json(self):
+ return self._body
+
+
+def _bot():
+ from monitoring.discord_bot import DiscordBot
+
+ bot = DiscordBot.__new__(DiscordBot)
+ bot.enabled = True
+ bot.webhook_url = "https://discord.example/api/webhooks/1/SECRET"
+ bot.username = "t"
+ bot.avatar_url = ""
+ return bot
+
+
+def test_discord_rejection_is_logged_without_webhook_secret(monkeypatch):
+ import monitoring.discord_bot as db
+ from loguru import logger
+
+ lines = []
+ sink = logger.add(lambda m: lines.append(str(m)), level="ERROR")
+ try:
+ monkeypatch.setattr(db.req, "post", lambda *a, **k: _Resp(404, text="Unknown Webhook"))
+ assert _bot().send_message("hi") is False
+ finally:
+ logger.remove(sink)
+ joined = "".join(lines)
+ assert "ALERT_FAILED" in joined and "404" in joined
+ assert "SECRET" not in joined
+
+
+def test_discord_429_retries_once(monkeypatch):
+ import monitoring.discord_bot as db
+
+ calls = []
+
+ def _post(*a, **k):
+ calls.append(1)
+ return _Resp(429, {"retry_after": 0}) if len(calls) == 1 else _Resp(204)
+
+ monkeypatch.setattr(db.req, "post", _post)
+ assert _bot().send_message("hi") is True
+ assert len(calls) == 2
+
+
+def test_lost_alert_is_reported_when_every_channel_fails(monkeypatch):
+ from core.notifier import Notifier
+
+ n = Notifier.__new__(Notifier)
+ lost = []
+ monkeypatch.setattr(n, "_discord_send_message", lambda text: False, raising=False)
+ monkeypatch.setattr(n, "_send_email_tracked", lambda *a, **k: False, raising=False)
+ monkeypatch.setattr(n, "_discord_deliverable", lambda: True, raising=False)
+ monkeypatch.setattr(n, "_report_lost_alert", lambda t, x: lost.append(t), raising=False)
+ n.send_message("경보", critical=True)
+ assert lost == ["알림"]
diff --git a/tests/test_audit_data_freshness.py b/tests/test_audit_data_freshness.py
new file mode 100644
index 00000000..9789c351
--- /dev/null
+++ b/tests/test_audit_data_freshness.py
@@ -0,0 +1,242 @@
+"""지수 자료가 최신인지 확인하는 회귀 테스트 (2026-09-23 점검).
+
+FDR의 KS200·KS11 자료가 2026-09-17에서 멈췄다. 비어 있지 않은 표는 성공으로 보던
+탓에 폴백이 한 번도 시도되지 않았고, kr_pocket 추세 필터는 '직전 상태 유지'로 동결,
+평가의 'NAV vs KOSPI'는 며칠 전 종가로 계산됐다 — 전부 경고 로그만 남았다.
+"""
+
+from datetime import date, datetime, timedelta
+from unittest.mock import MagicMock
+
+import pandas as pd
+import pytest
+
+import core.data_collector as dc_mod
+from core.data_collector import DataCollector
+
+
+def _frame(days, start=100.0):
+ idx = pd.DatetimeIndex([pd.Timestamp(d) for d in days], name="date")
+ closes = [start + i for i in range(len(days))]
+ return pd.DataFrame(
+ {"open": closes, "high": closes, "low": closes, "close": closes, "volume": 1000},
+ index=idx,
+ )
+
+
+@pytest.fixture
+def fixed_target(monkeypatch):
+ monkeypatch.setattr(dc_mod, "_freshness_target", lambda end: date(2026, 9, 22))
+
+
+def test_stale_fdr_tail_is_filled_from_yfinance(monkeypatch, fixed_target):
+ dc = DataCollector()
+ fdr_df = _frame(["2026-09-15", "2026-09-16", "2026-09-17"])
+ yf_df = _frame(["2026-09-16", "2026-09-17", "2026-09-18", "2026-09-21", "2026-09-22"], 200)
+ monkeypatch.setattr(dc, "_fetch_korean_stock_via_yfinance", lambda s, a, b: yf_df)
+
+ merged, source = dc._fill_stale_tail("KS200", fdr_df, "2026-09-23")
+
+ assert source == "FinanceDataReader+yfinance"
+ assert merged.index.max().date() == date(2026, 9, 22)
+ # 기존 FDR 봉은 바꾸지 않는다
+ assert merged.loc["2026-09-17", "close"] == fdr_df.loc["2026-09-17", "close"]
+ assert list(merged.index.strftime("%m-%d")) == ["09-15", "09-16", "09-17", "09-18", "09-21", "09-22"]
+
+
+def test_fresh_fdr_is_left_alone(monkeypatch, fixed_target):
+ dc = DataCollector()
+ fdr_df = _frame(["2026-09-21", "2026-09-22"])
+ called = MagicMock()
+ monkeypatch.setattr(dc, "_fetch_korean_stock_via_yfinance", called)
+
+ out, source = dc._fill_stale_tail("069500", fdr_df, "2026-09-23")
+
+ assert source == "FinanceDataReader" and out is fdr_df
+ called.assert_not_called()
+
+
+def test_old_requests_are_not_checked(monkeypatch):
+ """백테스트처럼 과거 구간 요청은 최신 여부를 확인하지 않는다(네트워크를 안 쓴다)."""
+ assert dc_mod._freshness_target("2020-01-31") is None
+
+
+def test_freshness_target_skips_holidays_and_today():
+ from core.trading_hours import _now_kst
+
+ target = dc_mod._freshness_target(_now_kst().date().isoformat())
+ assert target is not None and target < _now_kst().date()
+
+
+def test_benchmark_return_refuses_stale_last_bar(monkeypatch, fixed_target):
+ """마지막 봉이 기준 거래일보다 오래됐으면 그 값으로 수익률을 내지 않는다."""
+ import FinanceDataReader as fdr
+
+ stale = _frame(["2026-06-10", "2026-09-17"]).rename(columns={"close": "Close"})
+ monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: stale)
+ monkeypatch.setattr(dc_mod, "HAS_YF", False)
+
+ assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") is None
+
+
+def test_benchmark_return_uses_fresh_fallback(monkeypatch, fixed_target):
+ import FinanceDataReader as fdr
+
+ stale = _frame(["2026-06-10", "2026-09-17"]).rename(columns={"close": "Close"})
+ fresh = pd.DataFrame({"Close": [100.0, 90.0]},
+ index=pd.DatetimeIndex([pd.Timestamp("2026-06-10"), pd.Timestamp("2026-09-22")]))
+ monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: stale)
+ monkeypatch.setattr(dc_mod, "HAS_YF", True)
+ monkeypatch.setattr(dc_mod, "_yf_download_flat", lambda t, s, e: fresh)
+
+ assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") == pytest.approx(-10.0)
+
+
+def test_yfinance_fallback_maps_index_and_includes_end_day(monkeypatch):
+ dc = DataCollector()
+ seen = []
+
+ def _fake(ticker, start, end):
+ seen.append((ticker, end))
+ return pd.DataFrame(
+ {"Open": [1.0, 2.0], "High": [1.0, 2.0], "Low": [1.0, 2.0],
+ "Close": [1.0, 2.0], "Volume": [1, 1]},
+ index=pd.DatetimeIndex([pd.Timestamp("2026-09-21"), pd.Timestamp("2026-09-22")], name="Date"),
+ )
+
+ monkeypatch.setattr(dc_mod, "_yf_download_flat", _fake)
+ out = dc._fetch_korean_stock_via_yfinance("KS200", "2026-09-01", "2026-09-22")
+
+ assert seen[0] == ("^KS200", "2026-09-23") # end는 배타적 — 하루 뒤로
+ assert not out.empty and out.index.max().date() == date(2026, 9, 22)
+
+
+def test_yfinance_fallback_tries_kosdaq_suffix(monkeypatch):
+ dc = DataCollector()
+ tried = []
+
+ def _fake(ticker, start, end):
+ tried.append(ticker)
+ if ticker.endswith(".KS"):
+ return pd.DataFrame()
+ return pd.DataFrame(
+ {"Open": [1.0, 2.0], "High": [1.0, 2.0], "Low": [1.0, 2.0],
+ "Close": [1.0, 2.0], "Volume": [1, 1]},
+ index=pd.DatetimeIndex([pd.Timestamp("2026-09-21"), pd.Timestamp("2026-09-22")], name="Date"),
+ )
+
+ monkeypatch.setattr(dc_mod, "_yf_download_flat", _fake)
+ out = dc._fetch_korean_stock_via_yfinance("091990", "2026-09-01", "2026-09-22")
+
+ assert tried == ["091990.KS", "091990.KQ"]
+ assert not out.empty
+
+
+def test_flat_download_flattens_multiindex(monkeypatch):
+ cols = pd.MultiIndex.from_tuples([("Close", "^KS200"), ("Open", "^KS200")])
+ df = pd.DataFrame([[1.0, 1.0]], columns=cols)
+ monkeypatch.setattr(dc_mod.yf, "download", lambda *a, **k: df.copy())
+ out = dc_mod._yf_download_flat("^KS200", "2026-09-01", "2026-09-23")
+ assert list(out.columns) == ["Close", "Open"]
+
+
+# ------------------------------------------------------------ 추세 필터를 ETF로 대신 판단
+
+def _rebalancer_for_proxy(monkeypatch, index_df, proxy_df):
+ from core.basket_rebalancer import BasketRebalancer
+
+ rb = BasketRebalancer.__new__(BasketRebalancer)
+ rb.basket_name = "kr_pocket"
+ rb.config = MagicMock()
+ rb.data_collector = MagicMock()
+ rb.data_collector.fetch_korean_stock.side_effect = (
+ lambda sym, s, e: index_df if sym == "KS200" else proxy_df
+ )
+ rb._overlay_input_issues = []
+ rb._overlay_source_dates = {}
+ monkeypatch.setattr(rb, "_overlay_previous_session", lambda: date(2026, 9, 22))
+ return rb
+
+
+def test_trend_filter_falls_back_to_tracking_etf_when_index_is_stale(monkeypatch):
+ days = pd.bdate_range("2025-06-01", "2026-09-22")
+ stale_index = _frame([d for d in days if d <= pd.Timestamp("2026-09-17")])
+ proxy = _frame(list(days), 50_000)
+ rb = _rebalancer_for_proxy(monkeypatch, stale_index, proxy)
+
+ closes = rb._fetch_index_closes("KS200", 200)
+
+ assert closes is not None and len(closes) >= 200
+ assert closes[-1] == pytest.approx(proxy["close"].iloc[-1])
+ assert any("069500" in i for i in rb._overlay_input_issues)
+ # ETF로 판단했으니 '비중 확대 보류' 문구는 남기지 않는다
+ assert not any(i.startswith("지수 종가:") for i in rb._overlay_input_issues)
+ assert rb._overlay_source_dates.get("069500 종가(지수 대신)") == "2026-09-22"
+
+
+def test_trend_filter_keeps_issue_when_proxy_also_stale(monkeypatch):
+ days = pd.bdate_range("2025-06-01", "2026-09-17")
+ stale = _frame(list(days))
+ rb = _rebalancer_for_proxy(monkeypatch, stale, stale)
+
+ assert rb._fetch_index_closes("KS200", 200) is None
+ assert any(i.startswith("지수 종가:") for i in rb._overlay_input_issues)
+
+
+def test_overlay_message_names_the_real_cause():
+ from core.risk_overlays import compute_decision, parse_overlay_config
+
+ cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True, "ma_days": 200}}})
+ empty = compute_decision(cfg, index_closes=None, prev_state={"trend_below": False})
+ short = compute_decision(cfg, index_closes=[1.0] * 50, prev_state={"trend_below": False})
+ assert any("쓸 수 없음" in i for i in empty.data_issues)
+ assert any("200일치 부족" in i for i in short.data_issues)
+
+
+def test_proxy_note_does_not_claim_the_weight_was_held_back():
+ """069500으로 대신 판단해 비중을 제대로 정했는데 화면에 '비중 확대 보류'라고 쓰면
+ 실제와 반대로 알린다(리뷰 지적). 참고 사항과 실제 보류를 구분한다."""
+ from core.risk_overlays import compute_decision, describe_decision, parse_overlay_config
+
+ cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True, "ma_days": 200}}})
+ ok = compute_decision(cfg, index_closes=[100.0] * 260, prev_state={"trend_below": False})
+ ok.data_issues.append("KS200 지수 자료가 늦어 069500 종가로 추세를 봤음")
+ assert ok.held_back is False
+ text = describe_decision(ok)
+ assert "보류" not in text and "069500" in text
+
+ missing = compute_decision(cfg, index_closes=None, prev_state={"trend_below": False})
+ assert missing.held_back is True
+ assert "자료 확인 전 비중 확대 보류" in describe_decision(missing)
+
+ # held_back이 없는 예전 상태 파일은 자료 문제가 있으면 보류로 읽는다
+ legacy = {"scale": 1.0, "reasons": [], "data_issues": ["지수 종가: 최근 기록 없음"]}
+ assert "자료 확인 전 비중 확대 보류" in describe_decision(legacy)
+
+
+def test_health_lists_overlay_data_issues():
+ from core.operator_health import summarize_basket_operation
+
+ out = summarize_basket_operation(
+ ["kr_pocket"], date(2026, 9, 23), 2, date(2026, 9, 23),
+ data_notes=["바스켓 'kr_pocket' 위험 관리에 쓸 자료 문제: 지수 종가: 최근 기록 2026-09-17"],
+ )
+ assert out["verdict"] == "ATTENTION"
+ assert any("위험 관리에 쓸 자료 문제" in n for n in out["notes"])
+
+
+def test_benchmark_return_ignores_empty_close_rows(monkeypatch, fixed_target):
+ """yfinance가 값이 빈 봉을 끼워 줘도 NaN 수익률을 내지 않는다(실측: 9/22 빈 봉)."""
+ import FinanceDataReader as fdr
+
+ monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: pd.DataFrame())
+ gappy = pd.DataFrame(
+ {"Close": [100.0, 95.0, float("nan")]},
+ index=pd.DatetimeIndex([pd.Timestamp("2026-06-10"), pd.Timestamp("2026-09-21"),
+ pd.Timestamp("2026-09-22")]),
+ )
+ monkeypatch.setattr(dc_mod, "HAS_YF", True)
+ monkeypatch.setattr(dc_mod, "_yf_download_flat", lambda t, s, e: gappy)
+
+ # 마지막 유효 봉(9/21)이 기준(9/22)보다 오래됐으니 계산하지 않는다 — NaN도 아니다
+ assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") is None
diff --git a/tests/test_audit_evaluation.py b/tests/test_audit_evaluation.py
new file mode 100644
index 00000000..77fbcbe3
--- /dev/null
+++ b/tests/test_audit_evaluation.py
@@ -0,0 +1,156 @@
+"""평가 창 회귀 테스트 (2026-09-23 점검).
+
+- 규칙을 바꾼 트랙은 새 규칙 적용일 이후를 따로 센다(rules_window). 예전 규칙의
+ 운영 일수로 진행률이 100%가 되면 새 규칙을 검토할 근거가 없다.
+- 설계를 바꾼 트랙의 실행·구성 격차는 설계를 바꾼 날부터 잰다(design_effective_from).
+"""
+
+from datetime import date, datetime
+from unittest.mock import patch
+
+import pytest
+
+from database.models import init_database
+
+
+def _seed(key, days, *, cum=None, trade_day=None):
+ from database.models import TradeHistory, get_session
+ from database.repositories import save_portfolio_snapshot
+
+ init_database()
+ for i, d in enumerate(days):
+ save_portfolio_snapshot(
+ total_value=1_000_000, cash=400_000, invested=600_000,
+ cumulative_return=(cum[i] if cum else 0.0),
+ account_key=key, snapshot_date=datetime.combine(d, datetime.min.time()),
+ mode="paper",
+ )
+ if trade_day:
+ s = get_session()
+ try:
+ s.add(TradeHistory(
+ account_key=key, strategy=key, symbol="069500", action="BUY",
+ price=100_000, quantity=1, total_amount=100_000, commission=15,
+ mode="paper", executed_at=datetime.combine(trade_day, datetime.min.time()),
+ ))
+ s.commit()
+ finally:
+ s.close()
+
+
+def _collect(name, cfg, **kw):
+ from core.basket_evaluation import collect_basket_paper_evaluation
+
+ with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config",
+ return_value={name: cfg}):
+ result, _ = collect_basket_paper_evaluation(
+ basket_name=name, include_benchmark=False, **kw,
+ )
+ return result
+
+
+def test_rules_window_counts_only_days_under_new_rules():
+ name = "t_rules_window"
+ days = [date(2026, 9, 14), date(2026, 9, 15), date(2026, 9, 16),
+ date(2026, 9, 17), date(2026, 9, 18), date(2026, 9, 21)]
+ _seed(f"basket_rebalance:{name}", days, trade_day=date(2026, 9, 14))
+ cfg = {"enabled": True, "initial_capital": 1_000_000, "holdings": {"069500": 1.0},
+ "promotion": {"paper_only": True, "rules_effective_from": "2026-09-17"}}
+
+ r = _collect(name, cfg)
+
+ rw = r["rules_window"]
+ assert rw["since"] == "2026-09-17"
+ # 9/17, 9/18, 9/21 + (오늘까지 스냅샷 없는 거래일은 제외되지 않고 결측으로 센다)
+ assert rw["trading_days"] >= 3
+ assert r["progress_days"] >= rw["trading_days"] + 3 # 전체 운영 일수는 그대로
+ assert "rules_window" not in _collect(name, {**cfg, "promotion": {"paper_only": True}})
+
+
+def test_daily_card_shows_new_rules_progress():
+ from core.basket_evaluation import build_daily_report_extras
+
+ extras = build_daily_report_extras(eval_result={
+ "progress_days": 52, "min_trading_days": 60, "snapshot_days": 52,
+ "snapshot_coverage": 1.0, "progress_pct": 0.87, "paper_only": True,
+ "rules_window": {"trading_days": 5, "min_trading_days": 60},
+ "metrics": {},
+ })
+ assert "새 규칙 5/60일" in extras["progress"]
+
+
+def test_attribution_window_starts_at_design_date():
+ name = "t_design_window"
+ days = [date(2026, 8, 5), date(2026, 8, 6), date(2026, 8, 7), date(2026, 8, 10)]
+ # 8/06까지 누적 +10%, 8/10 누적 +12.2% → 설계 적용 이후 구간은 +2%
+ _seed(f"basket_rebalance:{name}", days, cum=[5.0, 10.0, 11.0, 12.2],
+ trade_day=date(2026, 8, 5))
+ cfg = {"enabled": True, "initial_capital": 1_000_000,
+ "holdings": {"005930": 1.0}, "target_stock_weight": 0.6,
+ "promotion": {"design_effective_from": "2026-08-07"}}
+ seen = {}
+
+ def _design(holdings, fraction, start, end):
+ seen["start"] = start
+ return 1.0
+
+ with patch("core.basket_evaluation.compute_design_portfolio_return", _design):
+ r = _collect(name, cfg, include_attribution=True)
+
+ assert seen["start"] == date(2026, 8, 7)
+ m = r["metrics"]
+ assert m["attribution_window"][0] == "2026-08-07"
+ assert m["attribution_nav_pct"] == pytest.approx(2.0, abs=1e-6)
+ assert m["execution_gap_pct"] == pytest.approx(1.0, abs=1e-6)
+
+
+# ------------------------------------------------------------ 승격 판정 NaN
+
+def _metrics(**over):
+ from core.promotion_engine import StrategyMetrics
+
+ base = dict(name="t", total_return=10.0, profit_factor=1.5, mdd=-10.0,
+ wf_positive_rate=0.8, wf_sharpe_positive_rate=0.8, wf_windows=5,
+ wf_total_trades=50, sharpe=1.2)
+ base.update(over)
+ return StrategyMetrics(**base)
+
+
+@pytest.mark.parametrize("field", ["total_return", "profit_factor", "wf_positive_rate"])
+def test_nan_core_metric_fails_paper_only(field):
+ from core.promotion_engine import _check_paper_only
+
+ ok, reason = _check_paper_only(_metrics(**{field: float("nan")}))
+ assert ok is False and field in reason
+
+
+@pytest.mark.parametrize("field", ["sharpe", "mdd"])
+def test_nan_risk_metric_fails_provisional_without_crash(field):
+ from core.promotion_engine import _check_provisional_candidate
+
+ ok, reason = _check_provisional_candidate(_metrics(**{field: float("nan")}))
+ assert ok is False and field in reason
+
+
+def test_missing_metric_fails_closed_instead_of_type_error():
+ from core.promotion_engine import _check_paper_only
+
+ ok, _ = _check_paper_only(_metrics(total_return=None))
+ assert ok is False
+
+
+def test_paper_order_errors_are_counted_but_not_gating():
+ from core.cycle_observability import record_cycle_event
+ from core.basket_evaluation import format_evaluation_report
+
+ name = "t_paper_errors"
+ key = f"basket_rebalance:{name}"
+ _seed(key, [date(2026, 9, 21)], trade_day=date(2026, 9, 21))
+ record_cycle_event("ORDER_ERROR", "예외", severity="critical", strategy=key, mode="paper")
+ cfg = {"enabled": True, "initial_capital": 1_000_000, "holdings": {"069500": 1.0}}
+
+ r = _collect(name, cfg)
+
+ assert r["metrics"]["paper_order_errors"] == 1
+ assert not any("오류" in i for i in r["issues"]) # 판정에는 넣지 않는다
+ assert "주문·실행 오류 1건" in format_evaluation_report(r, name)
diff --git a/tests/test_audit_exec.py b/tests/test_audit_exec.py
new file mode 100644
index 00000000..3b45934d
--- /dev/null
+++ b/tests/test_audit_exec.py
@@ -0,0 +1,287 @@
+"""주문 실행 경로 회귀 테스트 (2026-09-23 점검).
+
+- 노출 상한·낙폭 가드를 계획과 같은 시가로 잰다(원가로 재면 하락장 보충 매수가 거부되고,
+ 평가익이 쌓이면 가짜 '일일 손실'로 매수가 막힌다).
+- 목표 비중 주문은 계좌 낙폭 가드 대신 바스켓 낙폭 규칙을 따른다.
+- 매수 거부 사유를 로그·이벤트로 남긴다(요약의 '실패 N건'만으로는 원인을 모른다).
+- 손절 청산은 최소 보유 기간보다 우선한다(호출부가 emergency로 명시).
+- 재매수 차단 종목의 빈 슬롯이 드리프트 트리거를 매일 켜 두지 않는다.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from config.config_loader import Config
+
+
+@pytest.fixture
+def executor(monkeypatch):
+ Config._instance = None
+ from database.models import init_database
+
+ init_database()
+ from core.order_executor import OrderExecutor
+
+ ex = OrderExecutor(account_key="audit_exec_test")
+ ex.config.risk_params["drawdown"]["max_portfolio_mdd"] = 0.15
+ ex.config.risk_params["drawdown"]["max_daily_loss"] = 0.03
+ return ex
+
+
+def _fake_pm(summary, seen=None):
+ class FakePortfolioManager:
+ def __init__(self, config=None, account_key=""):
+ pass
+
+ def get_portfolio_summary(self, current_prices=None):
+ if seen is not None:
+ seen.append(current_prices)
+ return dict(summary)
+
+ return FakePortfolioManager
+
+
+# ------------------------------------------------------------ 목표 비중 주문과 낙폭 가드
+
+def test_mdd_breach_blocks_discretionary_buy(executor, monkeypatch):
+ monkeypatch.setattr("core.portfolio_manager.PortfolioManager",
+ _fake_pm({"total_value": 8_500_000, "mdd": 16.0}))
+ monkeypatch.setattr(executor, "_daily_loss_baseline", lambda: None)
+ r = executor._drawdown_pre_order_check("BUY")
+ assert r["allowed"] is False and r["drawdown_guard_type"] == "mdd"
+
+
+def test_mdd_breach_is_delegated_for_weight_policy_orders(executor, monkeypatch):
+ seen = []
+ monkeypatch.setattr("core.portfolio_manager.PortfolioManager",
+ _fake_pm({"total_value": 8_500_000, "mdd": 16.0}, seen))
+ monkeypatch.setattr(executor, "_daily_loss_baseline", lambda: None)
+
+ r = executor._drawdown_pre_order_check(
+ "BUY", mark_prices={"005930": 70_000.0}, delegated=True,
+ )
+
+ assert r["allowed"] is True
+ assert "MDD" in r["drawdown_guard_delegated"]
+ assert seen == [{"005930": 70_000.0}] # 판정 자체는 시가로 계산
+
+
+def test_basket_without_drawdown_rule_keeps_account_guard(executor, monkeypatch):
+ """낙폭 규칙을 꺼 둔 바스켓(kr_diversified_hold)의 목표 비중 주문은 계좌 가드를 그대로
+ 받는다. 넘기면 계좌 MDD 가드를 아무도 적용하지 않는다(리뷰 지적: live에서 사라짐)."""
+ monkeypatch.setattr("core.portfolio_manager.PortfolioManager",
+ _fake_pm({"total_value": 8_500_000, "mdd": 16.0}))
+ monkeypatch.setattr(executor, "_daily_loss_baseline", lambda: None)
+ monkeypatch.setattr(executor, "_global_trading_halt_check", lambda *a, **k: {"allowed": True})
+ monkeypatch.setattr(executor, "_monthly_buy_cap_check", lambda *a, **k: {"allowed": True})
+
+ r = executor._pre_order_check(
+ symbol="005930", action="BUY", mark_prices={"005930": 70_000.0},
+ weight_policy_managed=True, drawdown_delegable=False,
+ )
+
+ assert r["allowed"] is False and r["drawdown_guard_type"] == "mdd"
+
+
+def test_buy_quantity_delegates_only_with_basket_drawdown_rule(executor, monkeypatch):
+ seen = []
+
+ def _capture(**kw):
+ seen.append(kw.get("drawdown_delegable"))
+ return {"allowed": False, "reason": "stop-here"}
+
+ monkeypatch.setattr(executor, "_pre_order_check", _capture)
+ monkeypatch.setattr(executor, "_report_buy_rejection", lambda *a, **k: None)
+ monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False)
+ kwargs = dict(symbol="069500", price=40_000, quantity=1, capital=1_000_000,
+ available_cash=500_000, weight_policy_managed=True)
+
+ executor.execute_buy_quantity(**kwargs)
+ executor.execute_buy_quantity(**kwargs, basket_drawdown_rule=True)
+ executor.execute_buy_quantity(**{**kwargs, "weight_policy_managed": False},
+ basket_drawdown_rule=True)
+
+ assert seen == [False, True, False]
+
+
+def test_rebalancer_reports_its_drawdown_rule():
+ from core.basket_rebalancer import BasketRebalancer
+
+ rb = BasketRebalancer.__new__(BasketRebalancer)
+ rb._overlay_cfg = None
+ rb.basket = {"overlays": {"drawdown_guard": {"enabled": True, "trigger": -0.1,
+ "release": -0.05, "scale": 0.5}}}
+ assert rb._has_drawdown_rule() is True
+
+ rb._overlay_cfg = None
+ rb.basket = {"overlays": {"drawdown_guard": {"enabled": False}}}
+ assert rb._has_drawdown_rule() is False
+
+ rb._overlay_cfg = None
+ rb.basket = {}
+ assert rb._has_drawdown_rule() is False
+
+
+def test_infrastructure_failure_is_not_delegated(executor, monkeypatch):
+ """평가 불가·설정 오류는 넘기지 않는다 — 판단 근거가 없으면 여전히 막는다."""
+ executor.config.risk_params["drawdown"]["max_portfolio_mdd"] = "x"
+ r = executor._drawdown_pre_order_check("BUY", delegated=True)
+ assert r["allowed"] is False and r["drawdown_guard_type"] == "invalid_config"
+
+
+# ------------------------------------------------------------ 시가 노출
+
+def test_marked_position_value_uses_market_price(executor):
+ pos = SimpleNamespace(symbol="005930", quantity=10, avg_price=90_000, total_invested=900_000)
+ assert executor._marked_position_value(pos, {"005930": 70_000}) == pytest.approx(700_000)
+ # 가격이 없으면 기존 기준(투자원금)
+ assert executor._marked_position_value(pos, {"000660": 1}) == pytest.approx(900_000)
+ assert executor._marked_position_value(pos, None) == pytest.approx(900_000)
+
+
+def test_exposure_check_sees_market_values(executor, monkeypatch):
+ """원가 기준이면 거부되는 보충 매수가 시가 기준으로는 설계 범위 안이다.
+
+ 원가: 보유 5,936,700 / 총자산 9,556,410 = 62.1% → +269,000 매수 시 64.9% > 63%
+ 시가: 보유 4,760,200 / 총자산 8,379,910 = 56.8% → +269,000 매수 시 60.0% ≤ 63%
+ """
+ positions = [SimpleNamespace(symbol="005490", quantity=17, avg_price=349_217.6,
+ total_invested=5_936_700)]
+ monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kw: positions)
+ captured = {}
+
+ def _fake_div(**kw):
+ captured.update(kw)
+ return {"can_buy": False, "reason": "stop-here"}
+
+ monkeypatch.setattr(executor.risk_manager, "check_diversification", _fake_div)
+ monkeypatch.setattr(executor, "_pre_order_check", lambda **kw: {"allowed": True})
+ monkeypatch.setattr(executor, "_report_buy_rejection", lambda *a, **k: None)
+ # 장 초반·마감 진입 차단 시간대 판정은 실제 시계를 본다 — 실행 시각에 따라 노출
+ # 판정까지 가지 못하므로 고정한다.
+ monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False)
+
+ executor.execute_buy_quantity(
+ symbol="005490", price=269_000, quantity=1, capital=8_379_910,
+ available_cash=3_619_710, weight_policy_managed=True,
+ exposure_limits={"max_investment_ratio": 0.63},
+ mark_prices={"005490": 280_011.76},
+ )
+
+ assert captured["current_invested"] == pytest.approx(17 * 280_011.76)
+ assert captured["total_value"] == pytest.approx(8_379_910)
+
+
+# ------------------------------------------------------------ 거부 사유 기록
+
+def test_buy_rejection_is_logged_and_recorded_once_per_day(executor, monkeypatch):
+ import core.cycle_observability as co
+
+ recorded = []
+ monkeypatch.setattr(
+ co, "record_event_once_per_day",
+ lambda event_type, message, **kw: recorded.append((event_type, kw)) or True,
+ )
+ monkeypatch.setattr(
+ executor, "_execute_buy_quantity_impl",
+ lambda **kw: {"success": False, "reason": "전체 투자 비중 63% 초과"},
+ )
+
+ r = executor.execute_buy_quantity(
+ symbol="005490", price=269_000, quantity=1, capital=1, available_cash=1,
+ strategy="basket_rebalance:kr_diversified_hold",
+ )
+
+ assert r["success"] is False
+ assert recorded and recorded[0][0] == "ORDER_REJECTED"
+ assert recorded[0][1]["symbol"] == "005490"
+ assert recorded[0][1]["dedupe_key"] == "전체 투자 비중 63% 초과"
+
+
+# ------------------------------------------------------------ 손절 청산 우선
+
+def test_emergency_flag_reaches_sell_impl(executor, monkeypatch):
+ seen = {}
+
+ def _impl(*args, emergency=False, **kw):
+ seen["emergency"] = emergency
+ return {"success": True}
+
+ monkeypatch.setattr(executor, "_execute_sell_impl", _impl)
+ executor.execute_sell("005930", 70_000, 1, reason="리밸런싱: RISK_EXIT STOP_LOSS: 손절",
+ emergency=True)
+ assert seen["emergency"] is True
+
+
+# ------------------------------------------------------------ 드리프트 트리거
+
+def test_cooldown_slot_does_not_arm_drift_trigger(monkeypatch):
+ 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.config = MagicMock()
+ rb.config.trading = {"mode": "paper"}
+ rb.rebalance_cfg = {"trigger": "drift", "drift_threshold": 0.08, "deployment_band": 0.03}
+ # 손절로 비운 012330 슬롯만 크게 벌어져 있고 나머지는 거의 목표대로
+ monkeypatch.setattr(rb, "calculate_drift", lambda prices=None: {
+ "012330": {"drift": 0.111}, "005930": {"drift": -0.01}, "035720": {"drift": 0.02},
+ })
+ monkeypatch.setattr(rb, "_deployment_gap", lambda prices=None: -0.01)
+ monkeypatch.setattr(
+ "core.basket_rebalancer.symbols_in_reentry_cooldown",
+ lambda *a, **k: {"012330": "손절 후 재매수 차단"},
+ )
+
+ should, reason = rb.should_rebalance({})
+
+ assert should is False, reason
+
+
+def test_tradable_drift_still_arms_trigger(monkeypatch):
+ from core.basket_rebalancer import BasketRebalancer
+
+ rb = BasketRebalancer.__new__(BasketRebalancer)
+ rb.basket_name = "t"
+ rb.basket = {}
+ rb.account_key = "acct"
+ rb.config = MagicMock()
+ rb.config.trading = {"mode": "paper"}
+ rb.rebalance_cfg = {"trigger": "drift", "drift_threshold": 0.08}
+ monkeypatch.setattr(rb, "calculate_drift", lambda prices=None: {
+ "012330": {"drift": 0.111}, "005930": {"drift": -0.09},
+ })
+ monkeypatch.setattr(
+ "core.basket_rebalancer.symbols_in_reentry_cooldown",
+ lambda *a, **k: {"012330": "손절 후 재매수 차단"},
+ )
+
+ should, _ = rb.should_rebalance({})
+ assert should is True
+
+
+# ------------------------------------------------------------ 피크 오염
+
+def test_peak_only_advances_on_market_priced_summary(monkeypatch):
+ """가격 없이(평균단가로) 부른 요약이 피크를 올리면 다음 스냅샷이 그 값을 저장한다."""
+ from database.models import init_database
+
+ init_database()
+ from core.portfolio_manager import PortfolioManager
+
+ pos = SimpleNamespace(symbol="005930", quantity=10, avg_price=100_000.0)
+ monkeypatch.setattr("core.portfolio_manager.get_all_positions", lambda **kw: [pos])
+ pm = PortfolioManager(account_key="audit_peak_test", initial_capital=1_000_000)
+ start_peak = pm._peak_value
+
+ summary = pm.get_portfolio_summary() # 원가 평가 — 총액 2,000,000
+ assert pm._peak_value == pytest.approx(start_peak)
+ # 피크를 올리지 않아도 낙폭이 음수가 되면 안 된다(주문 가드가 abs()로 받아 100%로 읽는다)
+ assert summary["mdd"] == pytest.approx(0.0)
+
+ pm.get_portfolio_summary(current_prices={"005930": 90_000.0}) # 시가 1,900,000
+ assert pm._peak_value == pytest.approx(1_900_000)
diff --git a/tests/test_audit_health.py b/tests/test_audit_health.py
new file mode 100644
index 00000000..5f0b3f42
--- /dev/null
+++ b/tests/test_audit_health.py
@@ -0,0 +1,85 @@
+"""헬스 배치율 감시 회귀 테스트 (2026-09-23 점검).
+
+8/26에 '복원'한 배치율 5%p 감시가 실제로는 울릴 수 없었다. 허용 하한을 보유 슬롯별
+1주 가격의 합으로 잡아서 9종목 바스켓의 허용이 약 20%p였기 때문이다 — 8/07~8/26
+현금 래칫(61% → 54.9%) 상태를 그대로 넣어도 OK가 나왔다.
+"""
+
+import pytest
+
+from core.operator_health import (
+ structural_deployment_tolerance,
+ summarize_deployment,
+ unfixable_deployment_gap,
+)
+from core.risk_overlays import applied_stock_fraction, invested_fraction
+
+# 2026-09-23 kr_diversified_hold 실제 평균단가(8종목 보유)와 총자산
+HOLD_PRICES = [395_500, 362_000, 291_667, 229_500, 37_262, 302_167, 99_900, 156_000]
+HOLD_TOTAL = 9_240_010
+
+
+def test_cash_ratchet_state_is_now_attention():
+ unit = unfixable_deployment_gap(HOLD_PRICES, HOLD_TOTAL, min_trade=200_000, band=0.03)
+ tol = structural_deployment_tolerance(unit, HOLD_TOTAL, 0.05)
+ assert tol == pytest.approx(0.05, abs=0.01) # 설정값(5%p) 근처로 돌아와야 한다
+ assert summarize_deployment(0.549, 0.60, tolerance=tol)["verdict"] == "ATTENTION"
+
+
+def test_old_sum_of_slots_rule_would_have_hidden_it():
+ """예전 호출부가 넘기던 값(슬롯별 1주 가격 합)으로는 같은 상태가 OK였다."""
+ old_tol = structural_deployment_tolerance(sum(HOLD_PRICES), HOLD_TOTAL, 0.05)
+ assert old_tol > 0.2
+ assert summarize_deployment(0.549, 0.60, tolerance=old_tol)["verdict"] == "OK"
+
+
+def test_cheapest_executable_lot_respects_min_trade():
+ # 3.7만원짜리는 min_trade 20만원을 넘기려면 6주(22.4만원)를 사야 한다.
+ # 보충은 묶음이 남은 격차의 2배보다 작을 때만 사므로 못 메우는 격차는 그 절반.
+ unit = unfixable_deployment_gap([37_262], 1_000_000, min_trade=200_000, band=0.0)
+ assert unit == pytest.approx(6 * 37_262 / 2)
+
+
+def test_gap_smaller_than_half_a_share_is_tolerated():
+ # 잔고 40만, 지수 12.8만 단일 슬롯: 부족분 6만(15%p) < 1주의 절반(6.4만)이면
+ # 1주를 사면 오히려 격차가 커진다 — 매수 보류가 맞고 경보도 없어야 한다.
+ unit = unfixable_deployment_gap([128_000], 400_000, min_trade=50_000, band=0.03)
+ tol = structural_deployment_tolerance(unit, 400_000, 0.10)
+ assert tol == pytest.approx(0.16)
+ assert summarize_deployment(0.35, 0.50, tolerance=tol)["verdict"] == "OK"
+
+
+def test_gap_a_share_could_close_is_attention():
+ # 같은 계좌에서 부족분 20%p(8만)는 1주(12.8만)로 줄일 수 있다 — 남아 있으면 이상
+ unit = unfixable_deployment_gap([128_000], 400_000, min_trade=50_000, band=0.03)
+ tol = structural_deployment_tolerance(unit, 400_000, 0.10)
+ assert summarize_deployment(0.30, 0.50, tolerance=tol)["verdict"] == "ATTENTION"
+
+
+def test_gap_inputs_missing_fall_back_to_configured_floor():
+ assert unfixable_deployment_gap([], 1_000_000, min_trade=1, band=0.03) == 0.0
+ assert unfixable_deployment_gap([100_000], 0, min_trade=1, band=0.03) == 0.0
+ assert unfixable_deployment_gap(["x", None, -5], 1_000_000, min_trade=1) == 0.0
+ assert structural_deployment_tolerance(0.0, 1_000_000, 0.05) == pytest.approx(0.05)
+
+
+# ---------------------------------------------------------- 오버레이 적용 목표
+
+POCKET = {"069500": 0.5, "357870": 0.5}
+
+
+def test_invested_fraction_counts_defensive_symbol_as_invested():
+ """주식을 절반으로 줄여도 줄인 만큼 CD ETF를 사므로 투자 비중은 95% 그대로."""
+ state = {"scale": 0.5}
+ assert invested_fraction(POCKET, 0.95, state, "357870") == pytest.approx(0.95)
+ # 예전 헬스 기준(설계 × 배수)은 절반으로 내려가 CD ETF 매수 실패를 못 봤다
+ assert applied_stock_fraction(0.95, state) == pytest.approx(0.475)
+
+
+def test_invested_fraction_without_defensive_symbol_scales_down():
+ assert invested_fraction({"a": 1, "b": 1}, 0.6, {"scale": 0.5}) == pytest.approx(0.3)
+ assert invested_fraction({"a": 1}, 0.6, None) == pytest.approx(0.6)
+
+
+def test_invested_fraction_ignores_bad_state():
+ assert invested_fraction(POCKET, 0.95, {"scale": "x"}, "357870") == pytest.approx(0.95)
diff --git a/tests/test_audit_kis_5xx.py b/tests/test_audit_kis_5xx.py
new file mode 100644
index 00000000..fb1a12f4
--- /dev/null
+++ b/tests/test_audit_kis_5xx.py
@@ -0,0 +1,185 @@
+"""KIS 5xx 업무 오류 회귀 테스트 (감사: runtime-kis-5xx-business-errors, 조회 경로만).
+
+KIS 게이트웨이는 초당 한도 초과(EGW00201)와 토큰 무효·만료(EGW00121/EGW00123)를
+HTTP 500 + JSON 본문으로 돌려줄 수 있다. 예전에는 본문을 보지 않고 전부 서버
+장애로 세어, 조회 두 건의 재시도만으로 서킷이 열리고(60초간 손절 SELL까지 차단)
+무효 토큰은 로컬 만료 시각까지 갱신되지 않았다.
+
+주문(비멱등) POST는 의도적으로 기존 '응답 불명' 처리를 유지한다 — 5xx에서는
+브로커가 주문을 접수했는지 단정할 수 없으므로 재전송 금지(test_kis_order_idempotency).
+네트워크는 전부 모킹한다.
+"""
+
+from unittest.mock import patch
+
+import pytest
+import requests
+
+from api.circuit_breaker import CircuitState, get_breaker
+from api.kis_api import KISApi, KISOrderResponseUnknown, reset_shared_token_cache
+
+VTS_URL = "https://openapivts.koreainvestment.com:29443"
+_OK_BODY = {"rt_cd": "0", "output": {"stck_prpr": "70000"}}
+
+
+class _Resp:
+ def __init__(self, status_code, payload=None, headers=None, json_error=False):
+ self.status_code = status_code
+ self._payload = payload if payload is not None else {}
+ self.headers = headers or {}
+ self.ok = 200 <= status_code < 400
+ self.text = "Internal Server Error" if json_error else ""
+ self._json_error = json_error
+
+ def json(self):
+ if self._json_error:
+ raise ValueError("Expecting value: line 1 column 1 (char 0)")
+ return self._payload
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ raise requests.HTTPError(f"HTTP {self.status_code}")
+
+
+def _reset(b):
+ with b._lock:
+ b.state = CircuitState.CLOSED
+ b.failure_count = 0
+ b._half_open_probe_in_flight = False
+ b._half_open_probe_started_at = 0.0
+
+
+@pytest.fixture(autouse=True)
+def _isolate_breaker():
+ b = get_breaker()
+ _reset(b)
+ yield
+ _reset(b)
+
+
+def _bare_api():
+ api = object.__new__(KISApi)
+ api.use_mock = True
+ api.cano = "12345678"
+ api.acnt_prdt_cd = "01"
+ api.base_url = "https://example.test"
+ api._is_configured = lambda: True
+ api._get_headers = lambda tr_id: {}
+ api._wait_for_token = lambda: None
+ api._backoff_with_jitter = lambda *a, **kw: 0.0
+ return api
+
+
+def test_get_rate_limit_body_retries_without_breaker_failure():
+ """GET 500 + EGW00201은 429처럼 대기 후 재시도하고 서킷 실패로 세지 않는다."""
+ api = _bare_api()
+ responses = [
+ _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수를 초과하였습니다."}),
+ _Resp(200, _OK_BODY),
+ ]
+ slept = []
+ with patch("api.kis_api.requests.get", side_effect=lambda *a, **kw: responses.pop(0)), \
+ patch("api.kis_api.time.sleep", side_effect=lambda s: slept.append(s)):
+ data = api._request("GET", "/quote", "TR", params={}, max_retries=3)
+
+ assert data == _OK_BODY
+ assert get_breaker().failure_count == 0
+ assert slept == [1]
+ assert api._total_429s == 1
+
+
+def test_get_rate_limit_bodies_do_not_open_breaker():
+ """EGW00201이 연달아 와도(조회 여러 건) 서킷이 열리지 않는다."""
+ api = _bare_api()
+ busy = _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수 초과"})
+ with patch("api.kis_api.requests.get", return_value=busy), \
+ patch("api.kis_api.time.sleep", lambda s: None):
+ for _ in range(3):
+ assert api._request("GET", "/quote", "TR", params={}, max_retries=3) == {}
+
+ breaker = get_breaker()
+ assert breaker.state == CircuitState.CLOSED
+ assert breaker.failure_count == 0
+
+
+def test_get_plain_5xx_is_still_a_breaker_failure():
+ """본문이 JSON이 아닌 진짜 서버 장애는 기존처럼 서킷 실패로 누적하고 재시도한다."""
+ api = _bare_api()
+ calls = {"get": 0}
+
+ def fake_get(*a, **kw):
+ calls["get"] += 1
+ return _Resp(503, json_error=True)
+
+ with patch("api.kis_api.requests.get", side_effect=fake_get), \
+ patch("api.kis_api.time.sleep", lambda s: None):
+ assert api._request("GET", "/quote", "TR", params={}, max_retries=3) == {}
+
+ assert calls["get"] == 3
+ assert get_breaker().failure_count == 3
+
+
+def test_order_post_with_rate_limit_body_keeps_response_unknown():
+ """주문 POST는 본문이 EGW00201이어도 재전송하지 않고 '응답 불명'으로 올린다(의도된 설계)."""
+ api = _bare_api()
+ calls = {"post": 0}
+
+ def fake_post(*a, **kw):
+ calls["post"] += 1
+ return _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수 초과"})
+
+ 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="HTTP 500"):
+ api._request("POST", "/order", "TR", body={"x": 1}, max_retries=3, idempotent=False)
+
+ assert calls["post"] == 1
+
+
+@pytest.fixture
+def kis_env(monkeypatch):
+ from config.config_loader import Config
+
+ config = Config.get()
+ orig = dict(config._settings.get("kis_api", {}))
+ config._settings.setdefault("kis_api", {})
+ config._settings["kis_api"].update({
+ "app_key": "PS_audit_5xx_key",
+ "app_secret": "audit_secret",
+ "account_no": "12345678-01",
+ "use_mock": True,
+ "mock_url": VTS_URL,
+ "max_retry": 3,
+ })
+ reset_shared_token_cache()
+ monkeypatch.setattr(KISApi, "_wait_for_token", lambda self: None)
+ monkeypatch.setattr(KISApi, "_notify_auth_failure", lambda self, message: None)
+ yield config
+ reset_shared_token_cache()
+ config._settings["kis_api"].clear()
+ config._settings["kis_api"].update(orig)
+
+
+@pytest.mark.parametrize("msg_cd", ["EGW00121", "EGW00123"])
+def test_get_token_error_body_refreshes_token_once(kis_env, msg_cd):
+ """GET 500 + 토큰 무효 코드는 서킷 실패가 아니라 토큰 재발급 후 재시도로 간다."""
+ post_calls, auth_headers = [], []
+
+ def fake_post(url, json=None, timeout=None, **kw):
+ post_calls.append(url)
+ return _Resp(200, {"access_token": f"tok-{len(post_calls)}", "expires_in": 86400})
+
+ def fake_get(url, headers=None, params=None, timeout=None):
+ auth_headers.append(headers["authorization"])
+ if headers["authorization"] == "Bearer tok-1":
+ return _Resp(500, {"rt_cd": "1", "msg_cd": msg_cd, "msg1": "유효하지 않은 token 입니다."})
+ return _Resp(200, _OK_BODY)
+
+ with patch("api.kis_api.requests.post", side_effect=fake_post), \
+ patch("api.kis_api.requests.get", side_effect=fake_get):
+ quote = KISApi().get_current_price("005930")
+
+ assert quote is not None and quote["price"] == 70000.0
+ assert post_calls == [f"{VTS_URL}/oauth2/tokenP"] * 2
+ assert auth_headers == ["Bearer tok-1", "Bearer tok-2"]
+ assert get_breaker().failure_count == 0
diff --git a/tests/test_audit_kis_breaker.py b/tests/test_audit_kis_breaker.py
new file mode 100644
index 00000000..2bf37773
--- /dev/null
+++ b/tests/test_audit_kis_breaker.py
@@ -0,0 +1,169 @@
+"""서킷 브레이커 락·probe 회귀 테스트 (감사: runtime-breaker-alert-io-under-lock).
+
+1) 발동 알림(Discord·SMTP I/O)을 락을 쥔 채 보내면 그동안 모든 KIS 호출이 멈춘다.
+2) HALF_OPEN probe가 429/400/401/403 같은 확정 응답을 받으면 성공도 실패도 아니라
+ probe 점유가 풀리지 않아 60초 동안 모든 요청이 이유 없이 막혔다.
+네트워크는 전부 모킹한다.
+"""
+
+import threading
+import time
+from unittest.mock import patch
+
+import pytest
+
+from api.circuit_breaker import CircuitBreaker, CircuitState, get_breaker
+from api.kis_api import KISApi
+
+
+def _reset(b):
+ with b._lock:
+ b.state = CircuitState.CLOSED
+ b.failure_count = 0
+ b.last_failure_time = 0.0
+ b._half_open_probe_in_flight = False
+ b._half_open_probe_started_at = 0.0
+
+
+@pytest.fixture(autouse=True)
+def _reset_singleton_breaker():
+ b = get_breaker()
+ _reset(b)
+ yield
+ _reset(b)
+
+
+class _BlockingNotifier:
+ """send_message가 이벤트가 풀릴 때까지 멈추는 알림(느린 Discord 흉내)."""
+
+ entered = None
+ release = None
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def send_message(self, *args, **kwargs):
+ type(self).entered.set()
+ type(self).release.wait(timeout=5)
+
+
+def test_alert_is_sent_after_releasing_the_lock(monkeypatch):
+ """발동 알림 전송 중에도 다른 스레드의 can_request()는 즉시 반환된다."""
+ _BlockingNotifier.entered = threading.Event()
+ _BlockingNotifier.release = threading.Event()
+ monkeypatch.setattr("core.notifier.Notifier", _BlockingNotifier)
+
+ breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0)
+ for _ in range(4):
+ breaker.on_failure()
+
+ tripping = threading.Thread(target=breaker.on_failure, daemon=True)
+ tripping.start()
+ try:
+ assert _BlockingNotifier.entered.wait(timeout=2), "알림 전송이 시작되지 않음"
+ result = {}
+
+ def probe():
+ started = time.monotonic()
+ result["allowed"] = breaker.can_request()
+ result["elapsed"] = time.monotonic() - started
+
+ prober = threading.Thread(target=probe, daemon=True)
+ prober.start()
+ prober.join(timeout=1.0)
+ assert not prober.is_alive(), "알림 전송 동안 can_request()가 락에 막힘"
+ assert result["elapsed"] < 0.5
+ assert result["allowed"] is False
+ assert breaker.state == CircuitState.OPEN
+ finally:
+ _BlockingNotifier.release.set()
+ tripping.join(timeout=5)
+
+
+def test_release_probe_keeps_half_open_and_allows_next_probe():
+ """확정 응답 뒤 release_probe()는 닫지 않고(HALF_OPEN 유지) 다음 probe를 곧바로 허용한다."""
+ breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=60.0)
+ breaker.state = CircuitState.OPEN
+ breaker.last_failure_time = time.monotonic() - 61.0
+
+ assert breaker.can_request() is True
+ assert breaker.state == CircuitState.HALF_OPEN
+ assert breaker.can_request() is False
+
+ breaker.release_probe()
+
+ assert breaker.state == CircuitState.HALF_OPEN
+ assert breaker.can_request() is True
+ assert breaker.can_request() is False
+
+
+def test_release_probe_is_noop_when_closed():
+ breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0)
+ breaker.on_failure()
+ breaker.release_probe()
+ assert breaker.state == CircuitState.CLOSED
+ assert breaker.failure_count == 1
+
+
+def _bare_api():
+ api = object.__new__(KISApi)
+ api.use_mock = True
+ api.cano = "12345678"
+ api.acnt_prdt_cd = "01"
+ api.base_url = "https://example.test"
+ api._is_configured = lambda: True
+ api._get_headers = lambda tr_id: {}
+ api._wait_for_token = lambda: None
+ api._backoff_with_jitter = lambda *a, **kw: 0.0
+ return api
+
+
+class _Resp:
+ def __init__(self, status_code, payload=None, headers=None):
+ self.status_code = status_code
+ self._payload = payload or {}
+ self.headers = headers or {}
+
+ def json(self):
+ return self._payload
+
+ def raise_for_status(self):
+ return None
+
+
+def _open_breaker_ready_for_probe():
+ b = get_breaker()
+ with b._lock:
+ b.state = CircuitState.OPEN
+ b.last_failure_time = time.monotonic() - b.recovery_timeout - 1.0
+ return b
+
+
+@pytest.mark.parametrize("status_code", [400, 403])
+def test_request_releases_probe_on_definitive_client_error(status_code):
+ """HALF_OPEN probe가 400/403을 받으면 다음 요청이 60초를 기다리지 않는다."""
+ breaker = _open_breaker_ready_for_probe()
+ api = _bare_api()
+
+ with patch("api.kis_api.requests.get", return_value=_Resp(status_code)):
+ assert api._request("GET", "/quote", "TR", params={}, max_retries=1) == {}
+
+ assert breaker.state == CircuitState.HALF_OPEN
+ assert breaker.can_request() is True
+
+
+def test_request_retries_after_429_probe_instead_of_blocking():
+ """HALF_OPEN probe의 429 뒤 재시도가 '서킷 동작 — 재시도 중단'으로 끝나지 않는다."""
+ breaker = _open_breaker_ready_for_probe()
+ api = _bare_api()
+ responses = [
+ _Resp(429, headers={"Retry-After": "1"}),
+ _Resp(200, {"rt_cd": "0", "output": {"ok": True}}),
+ ]
+
+ with patch("api.kis_api.requests.get", side_effect=lambda *a, **kw: responses.pop(0)), \
+ patch("api.kis_api.time.sleep", lambda s: None):
+ data = api._request("GET", "/quote", "TR", params={}, max_retries=2)
+
+ assert data == {"rt_cd": "0", "output": {"ok": True}}
+ assert breaker.state == CircuitState.CLOSED
diff --git a/tests/test_audit_kis_rate_stats.py b/tests/test_audit_kis_rate_stats.py
new file mode 100644
index 00000000..9555bde6
--- /dev/null
+++ b/tests/test_audit_kis_rate_stats.py
@@ -0,0 +1,117 @@
+"""KIS 사용량 통계 프로세스 공유 회귀 테스트 (감사: runtime-rate-limit-stats-per-instance).
+
+호출 예산(토큰 버킷·분당 윈도우)은 이미 프로세스 공유였지만 총 요청·429·연결
+오류 카운터는 인스턴스 필드였다. 스케줄러는 통계를 찍을 때마다 새 KISApi를
+만들어서 '누적 0건, 429 0회'만 기록했고, 429 폭주가 로그에 드러나지 않았다.
+네트워크는 전부 모킹한다.
+"""
+
+import uuid
+from unittest.mock import patch
+
+import pytest
+import requests
+
+from api.circuit_breaker import CircuitState, get_breaker
+from api.kis_api import KISApi, reset_shared_token_cache
+
+VTS_URL = "https://openapivts.koreainvestment.com:29443"
+
+
+class _Resp:
+ def __init__(self, status_code=200, payload=None, headers=None):
+ self.status_code = status_code
+ self._payload = payload if payload is not None else {}
+ self.headers = headers or {}
+ self.ok = 200 <= status_code < 400
+ self.text = ""
+
+ def json(self):
+ return self._payload
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ raise requests.HTTPError(f"HTTP {self.status_code}")
+
+
+def _reset_breaker():
+ b = get_breaker()
+ with b._lock:
+ b.state = CircuitState.CLOSED
+ b.failure_count = 0
+ b._half_open_probe_in_flight = False
+ b._half_open_probe_started_at = 0.0
+
+
+@pytest.fixture
+def kis_env():
+ from config.config_loader import Config
+
+ config = Config.get()
+ orig = dict(config._settings.get("kis_api", {}))
+ config._settings.setdefault("kis_api", {})
+ # 호출 예산 레지스트리는 프로세스 전역이라, 다른 테스트의 누적치와 섞이지 않게
+ # 매번 새 app_key로 새 항목을 만든다.
+ config._settings["kis_api"].update({
+ "app_key": f"PS_audit_stats_{uuid.uuid4().hex[:8]}",
+ "app_secret": "audit_secret",
+ "account_no": "12345678-01",
+ "use_mock": True,
+ "mock_url": VTS_URL,
+ "max_retry": 3,
+ })
+ reset_shared_token_cache()
+ _reset_breaker()
+ yield config
+ reset_shared_token_cache()
+ _reset_breaker()
+ config._settings["kis_api"].clear()
+ config._settings["kis_api"].update(orig)
+
+
+def test_counters_are_shared_across_instances(kis_env):
+ """인스턴스 A가 겪은 요청·429가 새 인스턴스 B의 통계에 보인다."""
+ responses = [
+ _Resp(429, headers={"Retry-After": "1"}),
+ _Resp(200, {"rt_cd": "0", "output": {"stck_prpr": "70000"}}),
+ ]
+
+ def fake_get(url, headers=None, params=None, timeout=None):
+ return responses.pop(0)
+
+ token = _Resp(200, {"access_token": "tok-stats", "expires_in": 86400})
+ with patch("api.kis_api.requests.post", return_value=token), \
+ patch("api.kis_api.requests.get", side_effect=fake_get), \
+ patch("api.kis_api.time.sleep", lambda s: None):
+ a = KISApi()
+ assert a.get_current_price("005930") is not None
+
+ stats = KISApi().get_rate_limit_stats()
+ assert stats["total_requests"] == 2
+ assert stats["total_429s"] == 1
+ assert stats["total_conn_errors"] == 0
+ assert stats["requests_last_60s"] == 2
+
+
+def test_connection_errors_are_counted_process_wide(kis_env):
+ """연결 오류 누적도 새 인스턴스에서 그대로 보인다."""
+ def fake_get(url, headers=None, params=None, timeout=None):
+ raise requests.exceptions.ConnectionError("RST")
+
+ token = _Resp(200, {"access_token": "tok-stats", "expires_in": 86400})
+ with patch("api.kis_api.requests.post", return_value=token), \
+ patch("api.kis_api.requests.get", side_effect=fake_get), \
+ patch("api.kis_api.time.sleep", lambda s: None):
+ assert KISApi()._request("GET", "/quote", "TR", params={}, max_retries=2) == {}
+
+ assert KISApi().get_rate_limit_stats()["total_conn_errors"] == 2
+
+
+def test_bare_instance_counts_on_itself():
+ """_rate_state가 없는 테스트 더블은 인스턴스 속성으로 센다(공유 상태 오염 없음)."""
+ api = object.__new__(KISApi)
+ api._total_429s = 0
+
+ assert api._bump_usage_counter("total_429s") == 1
+ assert api._bump_usage_counter("total_429s") == 2
+ assert api._total_429s == 2
diff --git a/tests/test_audit_kis_token.py b/tests/test_audit_kis_token.py
new file mode 100644
index 00000000..6a22e6d1
--- /dev/null
+++ b/tests/test_audit_kis_token.py
@@ -0,0 +1,254 @@
+"""KIS 접근 토큰 프로세스 공유 회귀 테스트 (감사: runtime-kis-token-not-shared).
+
+KIS는 토큰 발급을 1분당 1회로 제한한다(EGW00133). 예전에는 KISApi 인스턴스마다
+토큰을 따로 발급해, live 한 사이클(동기화·잔고 요약·매수마다 새 인스턴스)에서
+두 번째 발급부터 거절되고 잔고 확인 실패 → live 매수 전면 보류 → 인증 실패 알림
+폭주로 이어졌다. 이제 (base_url, app_key)별 공유 토큰 하나를 쓰고, 발급 실패는
+공유 60초 쿨다운으로 재발급·알림을 한 번으로 모은다.
+
+네트워크는 전부 모킹한다 — 실제 KIS 엔드포인트를 부르지 않는다.
+"""
+
+import json
+import time
+from unittest.mock import patch
+
+import pytest
+import requests
+
+from api.circuit_breaker import CircuitState, get_breaker
+from api.kis_api import KISApi, reset_shared_token_cache
+
+VTS_URL = "https://openapivts.koreainvestment.com:29443"
+REAL_URL = "https://openapi.koreainvestment.com:9443"
+
+_PRICE_BODY = {
+ "rt_cd": "0",
+ "output": {
+ "stck_prpr": "70000",
+ "stck_oprc": "69000",
+ "stck_hgpr": "71000",
+ "stck_lwpr": "68000",
+ "acml_vol": "1000",
+ "prdy_ctrt": "1.0",
+ "stck_sdpr": "69300",
+ },
+}
+
+
+class _Resp:
+ def __init__(self, status_code=200, payload=None, headers=None):
+ self.status_code = status_code
+ self._payload = payload if payload is not None else {}
+ self.headers = headers or {}
+ self.ok = 200 <= status_code < 400
+ self.text = json.dumps(self._payload, ensure_ascii=False)
+
+ def json(self):
+ return self._payload
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ raise requests.HTTPError(f"HTTP {self.status_code}")
+
+
+def _reset_breaker():
+ b = get_breaker()
+ with b._lock:
+ b.state = CircuitState.CLOSED
+ b.failure_count = 0
+ b._half_open_probe_in_flight = False
+ b._half_open_probe_started_at = 0.0
+
+
+@pytest.fixture
+def kis_env(monkeypatch):
+ """KIS가 '설정됨'으로 보이게 하고 토큰 캐시·서킷·레이트리밋 대기를 격리한다."""
+ from config.config_loader import Config
+
+ config = Config.get()
+ orig = dict(config._settings.get("kis_api", {}))
+ config._settings.setdefault("kis_api", {})
+ config._settings["kis_api"].update({
+ "app_key": "PS_audit_token_key",
+ "app_secret": "audit_secret",
+ "account_no": "12345678-01",
+ "use_mock": True,
+ "mock_url": VTS_URL,
+ "base_url": REAL_URL,
+ "max_retry": 2,
+ })
+ reset_shared_token_cache()
+ _reset_breaker()
+ monkeypatch.setattr(KISApi, "_wait_for_token", lambda self: None)
+ alerts = []
+ monkeypatch.setattr(
+ KISApi, "_notify_auth_failure", lambda self, message: alerts.append(message)
+ )
+ yield {"config": config, "alerts": alerts}
+ reset_shared_token_cache()
+ _reset_breaker()
+ config._settings["kis_api"].clear()
+ config._settings["kis_api"].update(orig)
+
+
+def _token_post_factory(post_calls, *, status=200):
+ def fake_post(url, json=None, timeout=None, **kwargs):
+ post_calls.append(url)
+ if status != 200:
+ return _Resp(status, {
+ "error_code": "EGW00133",
+ "error_description": "접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)",
+ })
+ return _Resp(200, {"access_token": f"tok-{len(post_calls)}", "expires_in": 86400})
+
+ return fake_post
+
+
+def test_instances_share_one_token_issuance(kis_env):
+ """인스턴스 3개가 각각 시세를 조회해도 토큰 발급은 1번이다."""
+ post_calls, auth_headers = [], []
+
+ def fake_get(url, headers=None, params=None, timeout=None):
+ auth_headers.append(headers["authorization"])
+ return _Resp(200, _PRICE_BODY)
+
+ with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)), \
+ patch("api.kis_api.requests.get", side_effect=fake_get):
+ for _ in range(3):
+ quote = KISApi().get_current_price("005930")
+ assert quote is not None and quote["price"] == 70000.0
+
+ assert post_calls == [f"{VTS_URL}/oauth2/tokenP"]
+ assert auth_headers == ["Bearer tok-1"] * 3
+
+
+def test_explicit_authenticate_reuses_valid_shared_token(kis_env):
+ """executor 생성·live 시작 사전 발급처럼 authenticate()를 직접 불러도 재발급하지 않는다."""
+ post_calls = []
+ with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)):
+ first = KISApi()
+ assert first.authenticate() is True
+ second = KISApi()
+ # 새 인스턴스는 생성 시점에 이미 공유 토큰을 미러로 들고 있다(헬스체크·
+ # order_executor가 읽는 _access_token 호환).
+ assert second._access_token == "tok-1"
+ assert second.authenticate() is True
+
+ assert len(post_calls) == 1
+ assert second._access_token == first._access_token == "tok-1"
+
+
+def test_failed_issuance_sets_shared_cooldown_and_alerts_once(kis_env):
+ """발급 실패는 공유 쿨다운 — 다른 인스턴스도 재발급·알림·'Bearer None' 요청을 하지 않는다."""
+ post_calls, get_calls = [], []
+
+ def fake_get(url, headers=None, params=None, timeout=None):
+ get_calls.append(headers.get("authorization"))
+ return _Resp(200, _PRICE_BODY)
+
+ with patch(
+ "api.kis_api.requests.post",
+ side_effect=_token_post_factory(post_calls, status=403),
+ ), patch("api.kis_api.requests.get", side_effect=fake_get):
+ a, b = KISApi(), KISApi()
+ assert a.get_current_price("005930") is None
+ assert b.get_current_price("005930") is None
+ assert b.authenticate() is False
+
+ assert len(post_calls) == 1
+ assert len(kis_env["alerts"]) == 1
+ assert "EGW00133" in kis_env["alerts"][0] or "1분당 1회" in kis_env["alerts"][0]
+ assert get_calls == [] # 토큰 없이 서버를 때리지 않는다
+ status = b.token_status()
+ assert status["valid"] is False
+ assert status["cooldown_remaining"] > 0
+ assert "HTTP 403" in status["last_error"]
+ assert a.get_rate_limit_stats()["token_cooldown_active"] is True
+
+
+def test_success_clears_shared_failure_state(kis_env):
+ """쿨다운이 끝난 뒤 다른 인스턴스가 발급에 성공하면 실패 상태가 즉시 사라진다."""
+ post_calls = []
+ with patch(
+ "api.kis_api.requests.post",
+ side_effect=_token_post_factory(post_calls, status=403),
+ ):
+ failing = KISApi()
+ assert failing.authenticate() is False
+
+ # 쿨다운 만료를 흉내 낸다(공유 상태의 종료 시각을 과거로).
+ failing._get_token_state()["error_until"] = time.monotonic() - 1.0
+
+ with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)):
+ other = KISApi()
+ assert other.authenticate() is True
+
+ status = failing.token_status()
+ assert status["valid"] is True
+ assert status["cooldown_remaining"] == 0.0
+ assert status["last_error"] == ""
+ assert failing._token_error_until == 0.0
+
+
+def test_mock_and_real_domains_never_share_a_token(kis_env):
+ """모의(VTS)와 실전은 base_url이 달라 토큰을 절대 공유하지 않는다."""
+ post_calls = []
+ config = kis_env["config"]
+ with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)):
+ vts = KISApi()
+ assert vts.authenticate() is True
+ config._settings["kis_api"]["use_mock"] = False
+ real = KISApi()
+ assert real._access_token is None
+ assert real.authenticate() is True
+
+ assert post_calls == [f"{VTS_URL}/oauth2/tokenP", f"{REAL_URL}/oauth2/tokenP"]
+ assert vts._access_token == "tok-1"
+ assert real._access_token == "tok-2"
+
+
+def test_401_refresh_is_single_flight_across_instances(kis_env):
+ """401은 거절된 토큰만 폐기하고 한 번 재발급한다. 이미 갱신됐으면 재발급하지 않는다."""
+ post_calls, auth_headers = [], []
+
+ def fake_get(url, headers=None, params=None, timeout=None):
+ auth_headers.append(headers["authorization"])
+ if headers["authorization"] == "Bearer tok-1":
+ return _Resp(401, {"rt_cd": "1", "msg1": "기간이 만료된 token 입니다."})
+ return _Resp(200, _PRICE_BODY)
+
+ with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)), \
+ patch("api.kis_api.requests.get", side_effect=fake_get):
+ a, b = KISApi(), KISApi()
+ assert a.authenticate() is True
+ assert b.authenticate() is True
+ assert a.get_current_price("005930") is not None
+ # b는 옛 토큰(tok-1)으로 401을 받은 상황 — 이미 a가 갱신했으므로 발급 없이 채택.
+ assert b._acquire_token(rejected_token="tok-1") is True
+
+ assert len(post_calls) == 2
+ assert auth_headers == ["Bearer tok-1", "Bearer tok-2"]
+ assert b._access_token == "tok-2"
+
+
+def test_token_status_never_issues_a_token(kis_env):
+ """헬스체크용 상태 조회는 발급 요청을 만들지 않는다."""
+ with patch(
+ "api.kis_api.requests.post",
+ side_effect=AssertionError("token_status must not issue a token"),
+ ):
+ status = KISApi().token_status()
+
+ assert status["has_token"] is False
+ assert status["valid"] is False
+ assert status["cooldown_remaining"] == 0.0
+
+
+def test_bare_instance_does_not_touch_shared_registry(kis_env):
+ """__init__을 거치지 않은 테스트 더블은 공유 쿨다운을 오염시키지 않는다."""
+ bare = object.__new__(KISApi)
+ bare._token_error_until = time.monotonic() + 600
+
+ assert KISApi()._token_error_until == 0.0
+ assert bare._token_error_until > time.monotonic()
diff --git a/tests/test_audit_reporting.py b/tests/test_audit_reporting.py
new file mode 100644
index 00000000..5800ef9a
--- /dev/null
+++ b/tests/test_audit_reporting.py
@@ -0,0 +1,117 @@
+"""리포트 지표 회귀 테스트 (2026-09-23 점검).
+
+입금을 달력 날짜로 묶으면, 그날 10:07 스냅샷 뒤에 들어온 입금이나 주말 입금이
+엉뚱한 구간에 들어가 그 구간이 +25~36% 수익으로 잡힌다(#461과 같은 증상이 적립
+주기마다 재발). 입금이 처음 반영된 스냅샷 구간으로 묶어야 한다.
+"""
+
+from datetime import date, datetime
+from types import SimpleNamespace
+
+import pytest
+
+
+def _snap(d, created, value):
+ return SimpleNamespace(date=datetime.combine(d, datetime.min.time()),
+ created_at=created, total_value=value)
+
+
+def _flows(monkeypatch, rows):
+ monkeypatch.setattr("database.repositories.get_cash_flows",
+ lambda account_key, mode="paper": rows)
+
+
+def test_weekend_deposit_is_absorbed_by_next_snapshot(monkeypatch):
+ import main
+ from core.performance_lens import daily_returns_from_nav
+
+ snaps = [
+ _snap(date(2026, 9, 25), datetime(2026, 9, 25, 10, 7), 300_000),
+ _snap(date(2026, 9, 28), datetime(2026, 9, 28, 10, 7), 400_800),
+ ]
+ _flows(monkeypatch, [(datetime(2026, 9, 26, 11, 0), 100_000.0)]) # 토요일 입금
+
+ flows = main.account_flows_by_snapshot("acct", snaps)
+ assert flows == {date(2026, 9, 28): 100_000.0}
+ rets = daily_returns_from_nav([(s.date, s.total_value) for s in snaps], flows=flows)
+ assert rets[-1][1] == pytest.approx(0.2, abs=1e-6) # 0.8천 원 수익만 남는다(+0.2%)
+
+
+def test_deposit_after_same_day_snapshot_goes_to_next_interval(monkeypatch):
+ """8/26 실제 사례: 10:07 스냅샷 뒤 17:19 입금 — 날짜로 묶으면 8/26이 -26%, 8/27이 +36%."""
+ import main
+ from core.performance_lens import daily_returns_from_nav
+
+ snaps = [
+ _snap(date(2026, 8, 25), datetime(2026, 8, 25, 10, 7), 280_718),
+ _snap(date(2026, 8, 26), datetime(2026, 8, 26, 10, 7), 284_499),
+ _snap(date(2026, 8, 27), datetime(2026, 8, 27, 10, 7), 385_460),
+ ]
+ _flows(monkeypatch, [(datetime(2026, 8, 26, 17, 19), 100_000.0)])
+
+ flows = main.account_flows_by_snapshot("acct", snaps)
+ assert flows == {date(2026, 8, 27): 100_000.0}
+ rets = [r for _, r in daily_returns_from_nav([(s.date, s.total_value) for s in snaps], flows=flows)]
+ assert all(abs(r) < 2.0 for r in rets), rets # 가짜 ±30%대 하루가 없어야 한다
+
+
+def test_flow_after_last_snapshot_is_not_yet_counted(monkeypatch):
+ import main
+
+ snaps = [_snap(date(2026, 9, 22), datetime(2026, 9, 22, 10, 7), 300_000)]
+ _flows(monkeypatch, [(datetime(2026, 9, 22, 18, 0), 100_000.0)])
+ assert main.account_flows_by_snapshot("acct", snaps) == {}
+
+
+def test_weekly_summary_does_not_call_reconstructed_week_accident_free():
+ from core.weekly_report import build_weekly_summary
+
+ base = dict(basket_name="t", eval_result={"verdict": "WAIT", "metrics": {}},
+ missing_days=0, cycle_errors=0)
+ clean = build_weekly_summary(**base)
+ restored = build_weekly_summary(**base, reconstructed_days=1)
+ ev = {f["name"]: f["value"] for f in restored["fields"]}["🛠 주간 이벤트"]
+ assert "무사고" in {f["name"]: f["value"] for f in clean["fields"]}["🛠 주간 이벤트"]
+ assert "무사고" not in ev and "나중에 채운 기록 1일" in ev
+
+
+def test_regime_note_is_shown():
+ from core.weekly_report import build_weekly_summary
+
+ regime = {"up": {"days": 3, "capture": 0.5, "bench_pct": 2.0, "mine_pct": 1.0},
+ "down": {"days": 2, "capture": 0.4, "bench_pct": -2.0, "mine_pct": -0.8}}
+ out = build_weekly_summary(basket_name="t", eval_result={"verdict": "WAIT", "metrics": {}},
+ regime=regime, regime_note="근사 — 지수 시가 기준")
+ val = {f["name"]: f["value"] for f in out["fields"]}["🌗 국면 분해"]
+ assert "근사" in val
+
+
+def test_sharpe_label_states_risk_free_rate():
+ from core.performance_lens import format_risk_line
+
+ line = format_risk_line({"samples": 30, "vol_annual_pct": 10.0, "sharpe_annual": 0.5,
+ "down_day_ratio": 0.4, "worst_day_pct": -2.0})
+ assert "샤프(금리 0% 기준)" in line
+
+
+def test_daily_card_renders_risk_field(monkeypatch):
+ from core.notifier import Notifier
+
+ sent = {}
+ n = Notifier.__new__(Notifier)
+ monkeypatch.setattr(n, "send_embed", lambda title, desc, **kw: sent.update(kw), raising=False)
+ n.send_daily_report({"total_value": 1, "risk": "연변동성 10.0% · 샤프(금리 0% 기준) +0.50"})
+ names = [f["name"] for f in sent["fields"]]
+ assert "📉 리스크" in names
+
+
+def test_deploy_cost_estimate_exempts_etf_sell_tax():
+ from core.basket_deploy import estimate_order_costs
+
+ orders = [SimpleNamespace(action="SELL", symbol="069500", quantity=1),
+ SimpleNamespace(action="SELL", symbol="005930", quantity=1)]
+ prices = {"069500": 100_000, "005930": 100_000}
+ with_exempt = estimate_order_costs(orders, prices, tax_exempt_symbols=["069500"])
+ without = estimate_order_costs(orders, prices)
+ assert with_exempt["est_tax"] == pytest.approx(200) # 005930만 0.20%
+ assert without["est_tax"] == pytest.approx(400)
diff --git a/tests/test_audit_research.py b/tests/test_audit_research.py
new file mode 100644
index 00000000..c449964b
--- /dev/null
+++ b/tests/test_audit_research.py
@@ -0,0 +1,51 @@
+"""연구 도구 회귀 테스트 (2026-09-23).
+
+관찰 트랙 오버레이 결론은 운영 트랙과 같은 조건(보유 종목·재조정 규칙·현금 이자)으로
+내야 한다. 하이닉스가 든 10종목·현금 3% 표로 9종목·무이자 트랙의 정책을 정했다.
+"""
+
+import pandas as pd
+import pytest
+
+from tools import risk_overlay_backtest as research
+
+
+def test_basket_symbols_come_from_config_not_the_old_list():
+ symbols = research.configured_basket_symbols()
+ assert "000660" not in symbols
+ assert len(symbols) == 9
+
+
+def test_sleeve_is_rebalanced_when_a_name_drifts():
+ idx = pd.date_range("2024-01-01", periods=4, freq="D")
+ # A만 두 배가 되면 동일비중 슬리브에서 A 비중이 50%→67%로 벌어진다(>8%p) → 재조정
+ panel = pd.DataFrame({"A": [1.0, 2.0, 2.0, 1.0], "B": [1.0, 1.0, 1.0, 1.0]}, index=idx)
+ held = (panel / panel.iloc[0]).mean(axis=1) # 첫날 비중을 들고만 간 경우
+ rebal = research.rebalanced_ew_index(panel, cost_rate=0.0)
+ # 재조정하면 A가 반토막 날 때 손실이 작다(보유만 하면 A 비중이 커진 채로 맞는다)
+ assert rebal.iloc[-1] > held.iloc[-1]
+
+
+def test_sharpe_uses_given_risk_free_rate():
+ idx = pd.date_range("2024-01-01", periods=300, freq="B")
+ daily = pd.Series(0.0005, index=idx)
+ daily.iloc[::7] = -0.001
+ twr = (1 + daily).cumprod()
+ frame = pd.DataFrame({
+ "twr": twr, "daily_return": daily, "drawdown": twr / twr.cummax() - 1,
+ "total": twr * 1e6, "contributed": 1e6,
+ })
+ extra = {"turnover_value": 0.0, "avg_exposure": 0.6}
+ s0 = research.metrics(frame, extra, rf_annual=0.0)["sharpe"]
+ s3 = research.metrics(frame, extra)["sharpe"]
+ assert s0 > s3
+
+
+def test_capture_uses_daily_average_not_compounded_regime_return():
+ """상승일만 몇 년치 복리로 이으면 지수 수익이 폭증해 포착률이 0 쪽으로 쏠린다."""
+ from core.performance_lens import split_by_regime
+
+ pairs = [(0.6, 1.0)] * 600 + [(-0.6, -1.0)] * 600
+ r = split_by_regime(pairs)
+ assert r["up"]["capture"] == pytest.approx(0.6, abs=0.01)
+ assert r["down"]["capture"] == pytest.approx(0.6, abs=0.01)
diff --git a/tests/test_audit_runtime_basket_owner.py b/tests/test_audit_runtime_basket_owner.py
new file mode 100644
index 00000000..8671e7ee
--- /dev/null
+++ b/tests/test_audit_runtime_basket_owner.py
@@ -0,0 +1,128 @@
+"""스케줄러는 바스켓을 거래하지 않는다 (감사: runtime-scheduler-basket-premarket-divergent).
+
+예전 스케줄러는 장전(08:50~09:00)에 바스켓 리밸런싱을 실행했다. live 주문은 거래
+시간 가드에 전부 거부되고, paper는 전일 종가로 체결됐으며, CLI 사이클의 손절·하루
+1회 거래 가드·스냅샷 보충이 빠져 있었다. 이제 바스켓 실행 경로는 일일 CLI
+(main.py --mode rebalance) 하나뿐이고, 스케줄러는 그 사실을 로그로만 남긴다.
+"""
+
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import numpy as np
+import pandas as pd
+import pytest
+from loguru import logger
+
+
+def _sample_ohlcv(days=60):
+ rng = np.random.default_rng(7)
+ dates = pd.date_range(end=datetime.now(), periods=days, freq="B")
+ prices = 50000 * np.cumprod(1 + rng.normal(0.0002, 0.01, days))
+ return pd.DataFrame({
+ "open": prices, "high": prices * 1.01, "low": prices * 0.99,
+ "close": prices, "volume": rng.integers(100_000, 1_000_000, days),
+ }, index=dates)
+
+
+@pytest.fixture
+def captured_warnings():
+ messages = []
+ sink_id = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING")
+ yield messages
+ logger.remove(sink_id)
+
+
+def test_pre_market_never_builds_or_executes_a_basket(monkeypatch, captured_warnings):
+ """live 모드 장전 단계에서도 BasketRebalancer를 만들지 않고, 실행 경로를 로그로 알린다."""
+ from core.scheduler import Scheduler
+
+ constructed = []
+
+ class FakeRebalancer:
+ @staticmethod
+ def get_enabled_baskets():
+ return ["kr_diversified_hold", "kr_pocket"]
+
+ def __init__(self, *args, **kwargs):
+ constructed.append((args, kwargs))
+
+ sample = _sample_ohlcv()
+
+ class FakeCollector:
+ def fetch_stock(self, symbol, start=None, end=None):
+ return sample.copy()
+
+ def check_source_consistency(self, mode="paper"):
+ return []
+
+ def has_kis_fallback_symbols(self):
+ return []
+
+ class FakeStrategy:
+ def generate_signal(self, df, symbol=None):
+ return {"signal": "HOLD", "score": 0, "close": float(df["close"].iloc[-1])}
+
+ monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer)
+ monkeypatch.setattr("core.data_collector.DataCollector", FakeCollector)
+ monkeypatch.setattr(
+ "core.market_regime.check_market_regime",
+ lambda config, collector=None: {"allow_buys": True, "position_scale": 1.0},
+ )
+ monkeypatch.setattr(
+ "core.scheduler.WatchlistManager",
+ lambda config: SimpleNamespace(resolve=lambda: ["005930"]),
+ )
+
+ scheduler = Scheduler(strategy_name="scoring")
+ scheduler._mode = "live"
+ scheduler._live_gate_validated = True
+ scheduler.discord = MagicMock()
+ scheduler._get_strategy = lambda: FakeStrategy()
+ scheduler._maybe_record_dashboard_signal = lambda *a, **kw: None
+
+ scheduler._run_pre_market()
+
+ assert constructed == []
+ owner_logs = [m for m in captured_warnings if "--mode rebalance" in m]
+ assert len(owner_logs) == 1
+ assert "kr_diversified_hold" in owner_logs[0] and "kr_pocket" in owner_logs[0]
+ assert "거래하지 않습니다" in owner_logs[0]
+
+
+def test_no_enabled_baskets_logs_nothing(monkeypatch, captured_warnings):
+ from core.scheduler import Scheduler
+
+ class FakeRebalancer:
+ @staticmethod
+ def get_enabled_baskets():
+ return []
+
+ monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer)
+ Scheduler.__new__(Scheduler)._log_basket_execution_owner()
+
+ assert not [m for m in captured_warnings if "--mode rebalance" in m]
+
+
+def test_scheduler_has_no_basket_execution_path():
+ """스케줄러 소스에 바스켓 주문 실행 경로가 되살아나지 않게 고정한다."""
+ from core.scheduler import Scheduler
+
+ assert not hasattr(Scheduler, "_run_basket_rebalance_check")
+ source = (Path(__file__).resolve().parents[1] / "core" / "scheduler.py").read_text(
+ encoding="utf-8"
+ )
+ for forbidden in ("plan_rebalance(", ".execute(orders", "save_daily_nav_snapshot("):
+ assert forbidden not in source
+
+
+def test_baskets_yaml_header_names_the_cli_as_only_path():
+ header = (Path(__file__).resolve().parents[1] / "config" / "baskets.yaml").read_text(
+ encoding="utf-8"
+ ).split("baskets:", 1)[0]
+
+ assert "스케줄러 장전 단계에서 실행" not in header
+ assert "python main.py --mode rebalance" in header
+ assert "바스켓을 거래하지 않습니다" in header
diff --git a/tests/test_audit_runtime_healthcheck.py b/tests/test_audit_runtime_healthcheck.py
new file mode 100644
index 00000000..935b50f5
--- /dev/null
+++ b/tests/test_audit_runtime_healthcheck.py
@@ -0,0 +1,118 @@
+"""스케줄러 헬스체크 회귀 테스트 (감사: runtime-healthcheck-always-fails).
+
+예전 헬스체크는 SQLAlchemy 2.0에서 문자열 SQL + Session.remove()로 정상 DB에서도
+항상 'DB 연결 실패'를, live에서는 새 KISApi의 빈 토큰을 보고 '토큰 없음'을 냈다.
+10분마다 critical 알림이 가서 진짜 장애와 구분할 수 없었다.
+DB는 conftest가 격리한 임시 DB를 쓰고, KIS 네트워크는 호출하지 않는다.
+"""
+
+import sys
+import time
+import types
+from collections import namedtuple
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+from api.kis_api import KISApi, reset_shared_token_cache
+
+VTS_URL = "https://openapivts.koreainvestment.com:29443"
+_DiskUsage = namedtuple("_DiskUsage", "total used free")
+
+
+@pytest.fixture
+def healthy_host(monkeypatch):
+ """디스크·메모리 검사가 실행 환경 상태에 좌우되지 않게 고정한다."""
+ monkeypatch.setattr(
+ "core.scheduler.shutil.disk_usage",
+ lambda path: _DiskUsage(500 * 1024 ** 3, 100 * 1024 ** 3, 400 * 1024 ** 3),
+ )
+ fake_psutil = types.ModuleType("psutil")
+ fake_psutil.virtual_memory = lambda: SimpleNamespace(percent=10.0)
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
+
+
+def _scheduler(mode="paper"):
+ from config.config_loader import Config
+ from core.scheduler import Scheduler
+
+ scheduler = Scheduler.__new__(Scheduler)
+ scheduler.strategy_name = "scoring"
+ scheduler.config = SimpleNamespace(
+ database=Config.get().database,
+ trading={"mode": mode},
+ )
+ return scheduler
+
+
+@pytest.fixture
+def kis_env():
+ from config.config_loader import Config
+
+ config = Config.get()
+ orig = dict(config._settings.get("kis_api", {}))
+ config._settings.setdefault("kis_api", {})
+ config._settings["kis_api"].update({
+ "app_key": "PS_audit_health_key",
+ "app_secret": "audit_secret",
+ "account_no": "12345678-01",
+ "use_mock": True,
+ "mock_url": VTS_URL,
+ })
+ reset_shared_token_cache()
+ yield config
+ reset_shared_token_cache()
+ config._settings["kis_api"].clear()
+ config._settings["kis_api"].update(orig)
+
+
+def test_paper_healthcheck_is_clean_on_healthy_db(healthy_host):
+ assert _scheduler("paper")._run_healthcheck() == []
+
+
+def test_db_failure_is_reported_exactly(healthy_host, monkeypatch):
+ def boom():
+ raise RuntimeError("db unreachable")
+
+ monkeypatch.setattr("database.models.get_session", boom)
+
+ assert _scheduler("paper")._run_healthcheck() == ["DB 연결 실패: db unreachable"]
+
+
+def _no_token_issuance():
+ return patch(
+ "api.kis_api.requests.post",
+ side_effect=AssertionError("healthcheck must not issue a KIS token"),
+ )
+
+
+def test_live_healthcheck_accepts_shared_token_without_issuing(healthy_host, kis_env):
+ state = KISApi()._get_token_state()
+ with state["lock"]:
+ state["access_token"] = "tok-shared"
+ state["expires_at"] = datetime.now() + timedelta(hours=1)
+
+ with _no_token_issuance():
+ assert _scheduler("live")._run_healthcheck() == []
+
+
+def test_live_healthcheck_before_first_request_is_not_an_issue(healthy_host, kis_env):
+ """첫 KIS 요청 전(토큰 발급 전)은 정상 — 10분마다 '토큰 없음' 오경보를 내지 않는다."""
+ with _no_token_issuance():
+ assert _scheduler("live")._run_healthcheck() == []
+
+
+def test_live_healthcheck_reports_failed_issuance(healthy_host, kis_env):
+ state = KISApi()._get_token_state()
+ with state["lock"]:
+ state["error_until"] = time.monotonic() + 30
+ state["last_error"] = "HTTP 403 / 접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)"
+
+ with _no_token_issuance():
+ issues = _scheduler("live")._run_healthcheck()
+
+ assert len(issues) == 1
+ assert issues[0].startswith("KIS API 토큰 발급 실패 상태 — HTTP 403")
+ assert "재발급 억제" in issues[0]
diff --git a/tests/test_audit_runtime_holidays.py b/tests/test_audit_runtime_holidays.py
new file mode 100644
index 00000000..643919aa
--- /dev/null
+++ b/tests/test_audit_runtime_holidays.py
@@ -0,0 +1,165 @@
+"""스케줄러 휴장일 자동 갱신 호출부 회귀 테스트 (감사: runtime-holidays-autoupdate-reverts-verified-calendar).
+
+스케줄러는 holidays.yaml을 실행 위치(CWD) 기준으로 찾아, 다른 폴더에서 띄우면 파일이
+'없다'고 보고 매일 갱신했다. 그리고 pykrx 휴장일 조회가 안 되는 환경(현재 설치 버전에
+get_market_trading_date_by_date 없음)에서는 갱신기가 대체 목록으로 검증된 달력을
+덮어쓸 수 있었다. 이 테스트는 호출부만 고정한다(갱신기 자체 수정은 별도).
+실제 holidays.yaml은 건드리지 않는다 — 갱신 함수는 전부 대역이다.
+"""
+
+import os
+import sys
+import time
+import types
+from datetime import datetime
+from pathlib import Path
+
+import pytest
+from loguru import logger
+
+import core.scheduler as scheduler_mod
+
+
+@pytest.fixture
+def captured_warnings():
+ messages = []
+ sink_id = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING")
+ yield messages
+ logger.remove(sink_id)
+
+
+@pytest.fixture
+def update_calls(monkeypatch):
+ calls = []
+
+ def fake_update(path=None, **kwargs):
+ calls.append(path)
+ return path
+
+ monkeypatch.setattr("core.holidays_updater.update_holidays_yaml", fake_update)
+ return calls
+
+
+def _scheduler(monkeypatch):
+ s = scheduler_mod.Scheduler.__new__(scheduler_mod.Scheduler)
+ s.config = None
+ s.rebuilt = []
+ monkeypatch.setattr(scheduler_mod, "TradingHours", lambda config: s.rebuilt.append(config) or "th")
+ s.trading_hours = "old"
+ return s
+
+
+def _stale_file(tmp_path, days_old=100):
+ path = tmp_path / "holidays.yaml"
+ path.write_text("holidays: []\n", encoding="utf-8")
+ old = time.time() - days_old * 86400
+ os.utime(path, (old, old))
+ return path
+
+
+def test_holidays_path_is_resolved_from_project_root():
+ project_root = Path(scheduler_mod.__file__).resolve().parents[1]
+ assert scheduler_mod._HOLIDAYS_PATH == project_root / "config" / "holidays.yaml"
+ assert scheduler_mod._HOLIDAYS_PATH.is_absolute()
+
+
+def test_other_working_directory_does_not_trigger_update(
+ monkeypatch, tmp_path, update_calls
+):
+ """다른 폴더에서 실행해도 최신 달력 파일을 찾아 갱신하지 않는다."""
+ fresh = tmp_path / "project" / "holidays.yaml"
+ fresh.parent.mkdir()
+ fresh.write_text("holidays: []\n", encoding="utf-8")
+ monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", fresh)
+ monkeypatch.setattr(scheduler_mod, "_pykrx_holiday_source_available", lambda: (True, ""))
+ elsewhere = tmp_path / "elsewhere"
+ elsewhere.mkdir()
+ monkeypatch.chdir(elsewhere)
+
+ s = _scheduler(monkeypatch)
+ s._maybe_update_holidays()
+
+ assert update_calls == []
+ assert s.trading_hours == "old"
+
+
+def test_stale_calendar_is_not_overwritten_when_pykrx_unavailable(
+ monkeypatch, tmp_path, update_calls, captured_warnings
+):
+ stale = _stale_file(tmp_path)
+ monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", stale)
+ monkeypatch.setattr(
+ scheduler_mod,
+ "_pykrx_holiday_source_available",
+ lambda: (False, "pykrx.stock.get_market_trading_date_by_date 없음"),
+ )
+
+ s = _scheduler(monkeypatch)
+ s._maybe_update_holidays()
+
+ assert update_calls == []
+ assert s.rebuilt == []
+ assert any("휴장일 자동 갱신 생략" in m for m in captured_warnings)
+
+
+def test_stale_calendar_updates_project_root_path_when_pykrx_available(
+ monkeypatch, tmp_path, update_calls
+):
+ stale = _stale_file(tmp_path)
+ monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", stale)
+ monkeypatch.setattr(scheduler_mod, "_pykrx_holiday_source_available", lambda: (True, ""))
+
+ s = _scheduler(monkeypatch)
+ s._maybe_update_holidays()
+
+ assert update_calls == [stale]
+ assert s.rebuilt == [None]
+ assert s.trading_hours == "th"
+
+
+@pytest.mark.parametrize("pykrx_ok, expected_calls", [(False, 0), (True, 1)])
+def test_new_year_trigger_also_respects_pykrx_availability(
+ monkeypatch, tmp_path, update_calls, pykrx_ok, expected_calls
+):
+ """연초(1/1~1/7) 트리거도 pykrx를 쓸 수 없으면 대체 목록으로 덮어쓰지 않는다."""
+ last_year = tmp_path / "holidays.yaml"
+ last_year.write_text("holidays: []\n", encoding="utf-8")
+ december = datetime(2026, 12, 30, 12, 0).timestamp()
+ os.utime(last_year, (december, december))
+ monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", last_year)
+ monkeypatch.setattr(
+ scheduler_mod, "_pykrx_holiday_source_available", lambda: (pykrx_ok, "" if pykrx_ok else "없음"),
+ )
+
+ class _NewYear(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return datetime(2027, 1, 2, 8, 0)
+
+ monkeypatch.setattr(scheduler_mod, "datetime", _NewYear)
+
+ _scheduler(monkeypatch)._maybe_update_holidays()
+
+ assert len(update_calls) == expected_calls
+
+
+def _fake_pykrx(monkeypatch, *, with_api):
+ stock = types.ModuleType("pykrx.stock")
+ if with_api:
+ stock.get_market_trading_date_by_date = lambda start, end: None
+ package = types.ModuleType("pykrx")
+ package.stock = stock
+ monkeypatch.setitem(sys.modules, "pykrx", package)
+ monkeypatch.setitem(sys.modules, "pykrx.stock", stock)
+
+
+def test_pykrx_probe_detects_missing_trading_date_api(monkeypatch):
+ _fake_pykrx(monkeypatch, with_api=False)
+ available, reason = scheduler_mod._pykrx_holiday_source_available()
+ assert available is False
+ assert "get_market_trading_date_by_date" in reason
+
+
+def test_pykrx_probe_accepts_available_api(monkeypatch):
+ _fake_pykrx(monkeypatch, with_api=True)
+ assert scheduler_mod._pykrx_holiday_source_available() == (True, "")
diff --git a/tests/test_audit_runtime_live_readiness.py b/tests/test_audit_runtime_live_readiness.py
new file mode 100644
index 00000000..decd4df3
--- /dev/null
+++ b/tests/test_audit_runtime_live_readiness.py
@@ -0,0 +1,163 @@
+"""live 게이트 데이터 소스 신선도 회귀 테스트 (감사: runtime-live-readiness-datasource-check-fixed-window).
+
+예전 게이트는 005930의 고정 구간(2026-01-01~03-26)만 받아 봐서, 과거 이력은 주지만
+갱신이 멈춘 피드와 바스켓 자신의 종목(069500/357870) 문제를 잡지 못했다. 이제 보유
+종목 전부 + 벤치마크의 최근 봉이 직전 거래일에서 1거래일 이내인지 본다.
+네트워크는 호출하지 않는다(가짜 DataCollector). 거래일 달력은 테스트가 고정한다.
+"""
+
+from datetime import date, datetime
+
+import pandas as pd
+import pytest
+from loguru import logger
+
+from config.config_loader import Config
+from core.live_readiness import check_data_source_freshness, check_live_readiness_gate
+
+CHUSEOK_2026_WITH_0928 = {
+ "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-28",
+ "2026-10-03", "2026-10-05", "2026-10-09",
+}
+POCKET = {"kr_pocket": {"enabled": True, "holdings": {"069500": 0.5, "357870": 0.5}}}
+
+
+@pytest.fixture(autouse=True)
+def calendar(monkeypatch):
+ monkeypatch.setattr("core.trading_hours._load_holidays", lambda: set(CHUSEOK_2026_WITH_0928))
+
+
+@pytest.fixture
+def pocket_config(monkeypatch):
+ monkeypatch.setattr(
+ "core.basket_rebalancer.BasketRebalancer._load_baskets_config",
+ staticmethod(lambda: POCKET),
+ )
+
+
+class FakeCollector:
+ """종목별 마지막 봉 날짜를 지정할 수 있는 가짜 수집기."""
+
+ def __init__(self, last_bar_by_symbol=None, default_last_bar=None, source="FinanceDataReader"):
+ self.last_bar_by_symbol = last_bar_by_symbol or {}
+ self.default_last_bar = default_last_bar
+ self.source = source
+ self.requested = []
+
+ def fetch_korean_stock(self, symbol, start_date=None, end_date=None):
+ self.requested.append((symbol, start_date, end_date))
+ last = self.last_bar_by_symbol.get(symbol, self.default_last_bar)
+ if last is None:
+ return pd.DataFrame()
+ index = pd.bdate_range(end=pd.Timestamp(last), periods=5)
+ return pd.DataFrame({"close": [100.0] * len(index)}, index=index)
+
+ def get_last_source_info(self):
+ return {
+ "source": self.source,
+ "history": {symbol: self.source for symbol, _s, _e in self.requested},
+ }
+
+
+def test_stale_fixed_window_feed_is_rejected(pocket_config):
+ """2026-03-26에서 멈춘 피드는 모든 종목·벤치마크에서 거부된다."""
+ collector = FakeCollector(default_last_bar="2026-03-26")
+
+ issues = check_data_source_freshness(
+ Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector,
+ )
+
+ assert [s for s, _a, _b in collector.requested] == ["069500", "357870", "KS11"]
+ assert len(issues) == 3
+ assert all("갱신 멈춤 의심" in i and "2026-03-26" in i for i in issues)
+
+
+def test_fresh_feed_on_previous_trading_day_passes(pocket_config):
+ collector = FakeCollector(default_last_bar="2026-09-22")
+ messages = []
+ sink = logger.add(lambda m: messages.append(m.record["message"]), level="INFO")
+ try:
+ issues = check_data_source_freshness(
+ Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector,
+ )
+ finally:
+ logger.remove(sink)
+
+ assert issues == []
+ # 최근 약 10일 구간만 요청한다(고정 과거 구간이 아님).
+ assert all(start >= "2026-09-10" and end == "2026-09-23" for _s, start, end in collector.requested)
+ # 실제 마지막 봉 날짜를 로그로 남긴다.
+ assert any("069500" in m and "2026-09-22" in m for m in messages)
+
+
+def test_one_trading_day_lag_is_tolerated_but_two_is_not(pocket_config):
+ collector = FakeCollector(
+ last_bar_by_symbol={"069500": "2026-09-21", "357870": "2026-09-18"},
+ default_last_bar="2026-09-22",
+ )
+
+ issues = check_data_source_freshness(
+ Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector,
+ )
+
+ assert len(issues) == 1
+ assert "357870" in issues[0] and "2026-09-18" in issues[0]
+
+
+def test_after_chuseok_previous_trading_day_comes_from_calendar(pocket_config):
+ """9/29 점검: 달력상 직전 거래일은 9/23(9/24~28 휴장) — 9/23 봉이면 통과."""
+ collector = FakeCollector(default_last_bar="2026-09-23")
+
+ issues = check_data_source_freshness(
+ Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 29), collector=collector,
+ )
+
+ assert issues == []
+
+
+def test_signal_strategy_checks_representative_symbol_and_benchmark():
+ collector = FakeCollector(default_last_bar="2026-09-22")
+
+ issues = check_data_source_freshness(
+ Config.get(), "scoring", today=date(2026, 9, 23), collector=collector,
+ )
+
+ assert issues == []
+ assert [s for s, _a, _b in collector.requested] == ["005930", "KS11"]
+
+
+def test_kis_unadjusted_source_is_flagged(pocket_config):
+ collector = FakeCollector(default_last_bar="2026-09-22", source="KIS")
+
+ issues = check_data_source_freshness(
+ Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector,
+ )
+
+ assert len(issues) == 1 and "KIS(비수정주가)" in issues[0]
+
+
+def test_empty_feed_fails_closed(pocket_config):
+ issues = check_data_source_freshness(
+ Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23),
+ collector=FakeCollector(default_last_bar=None),
+ )
+ assert len(issues) == 3 and all("최근 데이터 없음" in i for i in issues)
+
+
+def test_gate_wires_freshness_check_with_basket_holdings(pocket_config, monkeypatch):
+ """배선: 바스켓 게이트 통과 후 게이트가 실제로 보유 종목 신선도 점검을 돈다."""
+ today = datetime.now().date()
+ created = []
+
+ class _Collector(FakeCollector):
+ def __init__(self):
+ super().__init__(default_last_bar=today.isoformat())
+ created.append(self)
+
+ monkeypatch.setattr("core.live_readiness.check_basket_live_readiness", lambda cfg, name: [])
+ monkeypatch.setattr("core.data_collector.DataCollector", _Collector)
+
+ issues = check_live_readiness_gate(Config.get(), "basket_rebalance:kr_pocket")
+
+ assert issues == []
+ assert [s for s, _a, _b in created[0].requested] == ["069500", "357870", "KS11"]
diff --git a/tests/test_audit_runtime_post_market.py b/tests/test_audit_runtime_post_market.py
new file mode 100644
index 00000000..28944a29
--- /dev/null
+++ b/tests/test_audit_runtime_post_market.py
@@ -0,0 +1,146 @@
+"""스케줄러 장마감 실행 창·일간 수익률 회귀 테스트 (감사: runtime-post-market-window-and-zero-daily-return).
+
+1) 장마감 분기가 hour==15일 때만 열려, 16시 이후 시작·재시작하면 그날 스냅샷·
+ 리포트·evidence·DB 백업이 조용히 사라졌다.
+2) 스케줄러 일일 리포트의 일간 수익률이 0으로 하드코딩돼 있었다(CLI는 TWR 계산).
+DB는 conftest가 격리한 임시 DB를 쓴다.
+"""
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+
+class _ClosedHours:
+ """장전·장중이 아닌 시각(장마감 분기만 가능)으로 고정한 TradingHours 대역."""
+
+ def is_pre_market(self, dt=None):
+ return False
+
+ def is_market_open(self, dt=None):
+ return False
+
+
+def _phase_scheduler():
+ from core.scheduler import Scheduler
+
+ s = Scheduler.__new__(Scheduler)
+ s.trading_hours = _ClosedHours()
+ s._pre_market_done = True
+ s._post_market_done = False
+ s.post_market_runs = []
+ s._run_post_market = lambda: s.post_market_runs.append(True)
+ return s
+
+
+@pytest.mark.parametrize("hour, minute", [(15, 35), (16, 10), (23, 5)])
+def test_post_market_runs_once_at_or_after_1535(hour, minute):
+ s = _phase_scheduler()
+ now = datetime(2026, 9, 23, hour, minute)
+
+ s._run_trading_day_phase(now)
+ s._run_trading_day_phase(now + timedelta(minutes=1))
+
+ assert s.post_market_runs == [True]
+ assert s._post_market_done is True
+
+
+def test_post_market_does_not_run_before_1535():
+ s = _phase_scheduler()
+ s._run_trading_day_phase(datetime(2026, 9, 23, 15, 34))
+ assert s.post_market_runs == []
+ assert s._post_market_done is False
+
+
+def _seed_snapshots(account, prev_total, last_total, deposit):
+ from database.repositories import record_cash_flow, save_portfolio_snapshot
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ save_portfolio_snapshot(
+ total_value=prev_total, cash=prev_total, invested=0,
+ account_key=account, snapshot_date=today - timedelta(days=1), mode="paper",
+ )
+ save_portfolio_snapshot(
+ total_value=last_total, cash=last_total, invested=0,
+ account_key=account, snapshot_date=today, mode="paper",
+ )
+ if deposit:
+ # 직전 스냅샷 측정(created_at) 뒤의 입금 — TWR 분모에 더해 중화돼야 한다.
+ record_cash_flow(deposit, account_key=account, note="audit deposit", mode="paper")
+
+
+def _report_scheduler(account):
+ from core.scheduler import Scheduler
+
+ s = Scheduler.__new__(Scheduler)
+ s.strategy_name = account
+ s._ledger_mode = "paper"
+ return s
+
+
+def test_report_daily_return_uses_twr_with_deposit_neutralized():
+ account = "audit_post_market_twr"
+ _seed_snapshots(account, 10_000_000, 10_250_000, 100_000)
+
+ expected = (10_250_000 / (10_000_000 + 100_000) - 1) * 100
+ assert _report_scheduler(account)._report_daily_return() == pytest.approx(expected)
+
+
+def test_report_daily_return_is_blank_with_single_snapshot():
+ from database.repositories import save_portfolio_snapshot
+
+ account = "audit_post_market_single"
+ save_portfolio_snapshot(
+ total_value=1_000_000, cash=1_000_000, invested=0, account_key=account, mode="paper",
+ )
+ # 비교할 직전 기록이 없으면 0.00%가 아니라 비워 둔다('—')
+ assert _report_scheduler(account)._report_daily_return() is None
+
+
+def test_post_market_report_card_carries_the_twr_daily_return(monkeypatch):
+ """배선 확인: 계산 함수가 있어도 리포트 카드에 실제로 실려야 한다."""
+ from core.scheduler import Scheduler
+
+ account = "audit_post_market_card"
+ _seed_snapshots(account, 20_000_000, 19_800_000, 0)
+
+ s = Scheduler.__new__(Scheduler)
+ s.strategy_name = account
+ s._ledger_mode = "paper"
+ s._mode = "paper"
+ s.config = SimpleNamespace(trading={"mode": "paper"}, risk_params={})
+ s.discord = MagicMock()
+ s.portfolio = MagicMock()
+ s.portfolio.save_daily_snapshot.return_value = True
+ s.portfolio.get_portfolio_summary.return_value = {
+ "total_value": 19_800_000, "cash": 19_800_000, "realized_pnl": 0,
+ "unrealized_pnl": 0, "total_return": -1.0, "mdd": -1.0, "position_count": 0,
+ }
+ s._collect_snapshot_prices = lambda: {}
+ s._check_live_readiness = lambda: None
+ s._collect_post_market_evidence = lambda date: None
+ s._loop_metrics = SimpleNamespace(
+ summary=lambda: {"total_loops": 0, "total_skips": 0}
+ )
+ monkeypatch.setattr(
+ "core.scheduler.get_daily_trade_summary",
+ lambda mode, account_key: {
+ "total_trades": 0, "buy_count": 0, "sell_count": 0, "realized_pnl": 0,
+ "total_commission": 0, "total_tax": 0, "winning_trades": 0, "losing_trades": 0,
+ },
+ )
+ monkeypatch.setattr("core.scheduler.diagnose_live_post_market", lambda **kw: [])
+ monkeypatch.setattr("core.scheduler.save_daily_report", lambda **kw: None)
+ monkeypatch.setattr("database.backup.run_daily_backup", lambda config: None)
+ # 금요일에 돌면 주간 리포트 파일을 쓰지 않게 막는다(실행 요일에 따라 결과가 바뀌지 않게).
+ monkeypatch.setattr(
+ "monitoring.paper_monitor.WeeklyReportGenerator",
+ lambda *a, **kw: SimpleNamespace(generate=lambda weeks_back=1: {}, save=lambda report: None),
+ )
+
+ s._run_post_market()
+
+ card = s.discord.send_daily_report.call_args.args[0]
+ assert card["daily_return"] == pytest.approx(-1.0)
diff --git a/tests/test_audit_runtime_rescan.py b/tests/test_audit_runtime_rescan.py
new file mode 100644
index 00000000..a22f461b
--- /dev/null
+++ b/tests/test_audit_runtime_rescan.py
@@ -0,0 +1,136 @@
+"""장중 재스캔·쿨다운 해제 재스캔 후보의 신선도 회귀 테스트 (감사: runtime-rescan-candidates-never-stale).
+
+두 재스캔 경로의 진입 후보에 timestamp가 없어 30분 경과 후보 폐기가 적용되지
+않았고(기본값 now), _signal_at이 없어 신호→주문 지연도 기록되지 않았다. 쿨다운
+해제 후보에는 시장 국면 스케일도 없었다. 네트워크는 호출하지 않는다.
+"""
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import numpy as np
+import pandas as pd
+import pytest
+
+T0 = datetime(2026, 9, 23, 10, 0)
+
+
+def _sample_ohlcv(days=60):
+ rng = np.random.default_rng(11)
+ dates = pd.date_range(end=T0, periods=days, freq="B")
+ prices = 50000 * np.cumprod(1 + rng.normal(0.0002, 0.01, days))
+ return pd.DataFrame({
+ "open": prices, "high": prices * 1.01, "low": prices * 0.99,
+ "close": prices, "volume": rng.integers(100_000, 1_000_000, days),
+ }, index=dates)
+
+
+class _BuyStrategy:
+ def generate_signal(self, df, symbol=None):
+ return {"signal": "BUY", "close": 50_000, "atr": 1_000, "score": 80}
+
+
+def _freeze(monkeypatch, moment):
+ class _Frozen(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return moment
+
+ monkeypatch.setattr("core.scheduler.datetime", _Frozen)
+
+
+@pytest.fixture
+def scheduler(monkeypatch):
+ from core.scheduler import Scheduler
+
+ sample = _sample_ohlcv()
+
+ class FakeCollector:
+ def fetch_stock(self, symbol, start=None, end=None):
+ return sample.copy()
+
+ monkeypatch.setattr("core.data_collector.DataCollector", FakeCollector)
+ monkeypatch.setattr(
+ "core.market_regime.check_market_regime",
+ lambda config, collector=None: {"allow_buys": True, "position_scale": 0.7},
+ )
+ monkeypatch.setattr(
+ "core.scheduler.WatchlistManager",
+ lambda cfg: SimpleNamespace(resolve=lambda: ["005930"]),
+ )
+ monkeypatch.setattr(
+ "core.scheduler.get_position", lambda symbol, account_key="", mode="paper": None,
+ )
+
+ s = Scheduler(strategy_name="scoring")
+ # live 모드로 두어 paper runtime/preflight 가드(별도 테스트 대상)를 건너뛴다.
+ s._mode = "live"
+ s._ledger_mode = "live"
+ s._entry_candidates = []
+ s._get_strategy = lambda: _BuyStrategy()
+ s._maybe_record_dashboard_signal = lambda *a, **kw: None
+ s.discord = MagicMock()
+ s.portfolio = SimpleNamespace(
+ get_portfolio_summary=lambda: {
+ "total_value": 10_000_000, "cash": 10_000_000, "current_value": 0,
+ }
+ )
+ s.blackswan = SimpleNamespace(get_recovery_scale=lambda: 1.0)
+ s.executed = []
+
+ class _Executor:
+ def execute_buy(self, **kwargs):
+ s.executed.append(kwargs)
+ return {"success": True, "symbol": kwargs["symbol"]}
+
+ s._get_or_create_executor = lambda: _Executor()
+ return s
+
+
+def test_intraday_rescan_candidate_carries_timestamp_and_signal_time(monkeypatch, scheduler):
+ _freeze(monkeypatch, T0)
+ scheduler._rescan_for_new_entries()
+
+ assert len(scheduler._entry_candidates) == 1
+ candidate = scheduler._entry_candidates[0]
+ assert candidate["timestamp"] == T0
+ assert candidate["_signal_at"] == T0
+ assert candidate["market_regime_scale"] == 0.7
+
+
+def test_post_cooldown_candidate_carries_timestamp_and_regime_scale(monkeypatch, scheduler):
+ _freeze(monkeypatch, T0)
+ scheduler._market_regime_scale = 0.5
+
+ scheduler._run_post_cooldown_rescan()
+
+ assert len(scheduler._entry_candidates) == 1
+ candidate = scheduler._entry_candidates[0]
+ assert candidate["timestamp"] == T0
+ assert candidate["_signal_at"] == T0
+ assert candidate["market_regime_scale"] == 0.5
+
+
+@pytest.mark.parametrize("rescan", ["_rescan_for_new_entries", "_run_post_cooldown_rescan"])
+def test_rescan_candidate_older_than_30_minutes_is_discarded(monkeypatch, scheduler, rescan):
+ _freeze(monkeypatch, T0)
+ getattr(scheduler, rescan)()
+ assert scheduler._entry_candidates
+
+ _freeze(monkeypatch, T0 + timedelta(minutes=31))
+ scheduler._execute_entry_candidates()
+
+ assert scheduler.executed == []
+ assert scheduler._entry_candidates == []
+
+
+def test_fresh_rescan_candidate_records_signal_latency(monkeypatch, scheduler):
+ _freeze(monkeypatch, T0)
+ scheduler._rescan_for_new_entries()
+
+ _freeze(monkeypatch, T0 + timedelta(minutes=10))
+ scheduler._execute_entry_candidates()
+
+ assert len(scheduler.executed) == 1
+ assert scheduler.executed[0]["signal_at"] == T0
diff --git a/tests/test_audit_runtime_trading_days.py b/tests/test_audit_runtime_trading_days.py
new file mode 100644
index 00000000..b1d20c06
--- /dev/null
+++ b/tests/test_audit_runtime_trading_days.py
@@ -0,0 +1,202 @@
+"""KRX 거래일 기준 evidence 기록·staleness 회귀 테스트.
+
+감사 두 건을 함께 고정한다(같이 배포해야 하는 쌍):
+- runtime-premarket-finalize-holiday-fabrication: 장전 '전일 finalize'가 주말만
+ 건너뛰어 평일 휴장일(추석·대체공휴일)의 가짜 real_paper evidence를 만들고, 실제
+ 직전 거래일은 finalize하지 않았다.
+- runtime-paper-runtime-weekday-stale: paper_runtime이 평일 수로 staleness를 세서
+ 연휴 뒤 첫 거래일에 증거가 밀렸다고 오판해 신규 진입을 막았다.
+
+거래일 달력은 테스트가 고정한다 — 운영 holidays.yaml이 교정돼도(예: 2026-09-28
+대체공휴일 오표기 의심) 이 테스트의 기대값이 흔들리지 않게.
+"""
+
+from datetime import date, datetime
+from types import SimpleNamespace
+
+import pytest
+from loguru import logger
+
+CHUSEOK_2026_WITH_0928 = {
+ "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-28",
+ "2026-10-03", "2026-10-05", "2026-10-09",
+}
+
+
+@pytest.fixture
+def calendar(monkeypatch):
+ holder = {"holidays": set(CHUSEOK_2026_WITH_0928)}
+ monkeypatch.setattr("core.trading_hours._load_holidays", lambda: set(holder["holidays"]))
+ return holder
+
+
+@pytest.fixture
+def captured_warnings():
+ messages = []
+ sink_id = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING")
+ yield messages
+ logger.remove(sink_id)
+
+
+def _scheduler():
+ from config.config_loader import Config
+ from core.scheduler import Scheduler
+ from core.trading_hours import TradingHours
+
+ s = Scheduler.__new__(Scheduler)
+ s.strategy_name = "scoring"
+ s._mode = "paper"
+ s.config = Config.get()
+ s.trading_hours = TradingHours(s.config)
+ s._pilot_session = {
+ "active": False, "pilot_authorized": False,
+ "pilot_caps_snapshot": {}, "session_mode": "normal_paper",
+ "evidence_mode": "real_paper",
+ }
+ return s
+
+
+@pytest.fixture
+def evidence_calls(monkeypatch):
+ calls = {"finalize": [], "collect": []}
+ monkeypatch.setattr(
+ "core.paper_evidence.finalize_daily_evidence",
+ lambda **kw: calls["finalize"].append(kw) or None,
+ )
+ monkeypatch.setattr(
+ "core.paper_evidence.collect_daily_evidence",
+ lambda **kw: calls["collect"].append(kw) or None,
+ )
+ monkeypatch.setattr(
+ "core.scheduler.WatchlistManager",
+ lambda cfg: SimpleNamespace(resolve=lambda: ["005930"]),
+ )
+ return calls
+
+
+def _frozen_datetime(frozen):
+ class _Frozen(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return frozen
+
+ return _Frozen
+
+
+def test_premarket_finalize_after_chuseok_targets_last_trading_day(
+ monkeypatch, calendar, evidence_calls
+):
+ """9/29 장전 finalize는 휴장일 9/28이 아니라 직전 거래일 9/23을 확정한다."""
+ monkeypatch.setattr(
+ "core.scheduler.datetime", _frozen_datetime(datetime(2026, 9, 29, 8, 55))
+ )
+
+ class _StopCollector:
+ def __init__(self, *a, **kw):
+ raise RuntimeError("테스트: 장전 분석 단계는 여기서 멈춘다")
+
+ monkeypatch.setattr("core.data_collector.DataCollector", _StopCollector)
+
+ _scheduler()._run_pre_market()
+
+ assert [c["date"].date() for c in evidence_calls["finalize"]] == [date(2026, 9, 23)]
+
+
+@pytest.mark.parametrize(
+ "now, holidays, expected",
+ [
+ (datetime(2026, 9, 29, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 9, 23)),
+ (datetime(2026, 9, 29, 8, 55), CHUSEOK_2026_WITH_0928 - {"2026-09-28"}, date(2026, 9, 28)),
+ (datetime(2026, 10, 6, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 10, 2)),
+ (datetime(2026, 10, 12, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 10, 8)),
+ (datetime(2026, 9, 22, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 9, 21)),
+ ],
+)
+def test_previous_trading_day_skips_weekday_holidays(calendar, now, holidays, expected):
+ calendar["holidays"] = set(holidays)
+ assert _scheduler()._previous_trading_day(now).date() == expected
+
+
+def test_finalize_refuses_holiday_without_raising(calendar, evidence_calls, captured_warnings):
+ """비거래일 finalize는 경고만 남기고 None — 가짜 real_paper 기록을 만들지 않는다."""
+ assert _scheduler()._finalize_evidence_for(datetime(2026, 9, 28)) is None
+ assert evidence_calls["finalize"] == []
+ assert any("2026-09-28" in m and "KRX 거래일이 아니" in m for m in captured_warnings)
+
+
+def test_post_market_collect_refuses_holiday(calendar, evidence_calls, captured_warnings):
+ assert _scheduler()._collect_post_market_evidence(datetime(2026, 9, 24, 15, 40)) is None
+ assert evidence_calls["collect"] == []
+ assert any("2026-09-24" in m for m in captured_warnings)
+
+
+def test_post_market_collect_runs_on_trading_day(calendar, evidence_calls):
+ _scheduler()._collect_post_market_evidence(datetime(2026, 9, 23, 15, 40))
+ assert [c["date"].date() for c in evidence_calls["collect"]] == [date(2026, 9, 23)]
+ assert evidence_calls["collect"][0]["evidence_mode"] == "real_paper"
+
+
+@pytest.mark.parametrize(
+ "start, end, expected",
+ [
+ ("2026-09-23", "2026-09-29", 1),
+ ("2026-10-02", "2026-10-06", 1),
+ ("2026-04-10", "2026-04-15", 3), # 휴장일 없는 구간은 평일 수와 같다
+ ("2026-09-23", "2026-09-23", 0),
+ ],
+)
+def test_runtime_staleness_counts_krx_trading_days(calendar, start, end, expected):
+ from core.paper_runtime import _trading_days_between
+
+ assert _trading_days_between(start, end) == expected
+
+
+def test_runtime_state_is_not_stale_after_chuseok(calendar, monkeypatch, tmp_path):
+ """9/23 증거로 9/29에 평가하면 신선하다(예전엔 평일 4일로 세서 신규 진입 차단)."""
+ import core.paper_evidence as pe
+ import core.paper_runtime as pr
+ from core.paper_evidence import _append_jsonl
+
+ monkeypatch.setattr(pe, "EVIDENCE_DIR", tmp_path / "paper_evidence")
+ monkeypatch.setattr(pr, "RUNTIME_DIR", tmp_path / "paper_runtime")
+ assert pr.PAPER_RUNTIME_MAX_EVIDENCE_STALE_TRADING_DAYS == 1
+
+ strategy = "audit_calendar_s"
+ path = tmp_path / "paper_evidence" / f"daily_evidence_{strategy}.jsonl"
+ for day_number, day in enumerate(["2026-09-21", "2026-09-22", "2026-09-23"], start=1):
+ _append_jsonl(path, {
+ "date": day, "day_number": day_number, "strategy": strategy,
+ "total_value": 10_000_000, "cash": 3_000_000, "invested": 7_000_000,
+ "daily_return": 0.1, "cumulative_return": 0.3, "mdd": -1.0,
+ "position_count": 2, "total_trades": 1,
+ "same_universe_excess": 0.05, "exposure_matched_excess": 0.03,
+ "cash_adjusted_excess": 0.02, "benchmark_status": "final",
+ "benchmark_meta": {"completeness": 1.0}, "raw_fill_rate": 1.0,
+ "reject_count": 0, "phantom_position_count": 0, "stale_pending_count": 0,
+ "duplicate_blocked_count": 0, "restart_recovery_count": 0,
+ "anomalies": [], "cross_validation_warnings": [], "status": "normal",
+ "record_version": 2, "schema_version": 2, "diagnostics": [],
+ })
+
+ state = pr.get_paper_runtime_state(strategy, as_of_date="2026-09-29")
+
+ assert state.metrics["evidence_stale_trading_days"] == 1
+ assert state.metrics["evidence_fresh"] is True
+ assert not any("stale_evidence" in r for r in state.reasons)
+ assert state.state == "normal"
+ assert "entry" in state.allowed_actions
+
+
+def test_pilot_business_days_warns_when_calendar_unavailable(monkeypatch, captured_warnings):
+ """달력 로드 실패는 조용히 평일 수로 떨어지지 않고 경고를 남긴다."""
+ import core.trading_hours as th
+ from core.paper_pilot import _business_days_between
+
+ class _Broken:
+ def __init__(self, *a, **kw):
+ raise RuntimeError("holidays.yaml 손상")
+
+ monkeypatch.setattr(th, "TradingHours", _Broken)
+
+ assert _business_days_between("2026-09-23", "2026-09-29") == 4
+ assert any("KRX 거래일 달력 로드 실패" in m for m in captured_warnings)
diff --git a/tests/test_audit_runtime_websocket.py b/tests/test_audit_runtime_websocket.py
new file mode 100644
index 00000000..a0161082
--- /dev/null
+++ b/tests/test_audit_runtime_websocket.py
@@ -0,0 +1,107 @@
+"""웹소켓 다건 프레임 파싱·문서 정합성 회귀 테스트 (감사: runtime-websocket-handler-unwired).
+
+1) KIS는 체결이 몰리면 한 프레임에 여러 건(data_count)을 이어 붙여 보내는데, 파서는
+ 첫 레코드만 읽고 나머지를 버렸다.
+2) PROJECT_GUIDE는 웹소켓 갭 처리를 완료(✅)로 표기했지만 어떤 런타임도 핸들러를
+ 시작하지 않는다. 문서와 실제 연결 상태가 다시 어긋나지 않게 함께 고정한다.
+"""
+
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from loguru import logger
+
+from api.websocket_handler import WebSocketHandler
+
+ROOT = Path(__file__).resolve().parents[1]
+H0STCNT0_FIELDS = 46 # KIS 국내주식 실시간 체결가 레코드 필드 수
+
+
+def _record(symbol, price, volume, width=H0STCNT0_FIELDS):
+ fields = ["0"] * width
+ fields[0] = symbol
+ fields[1] = "093001"
+ fields[2] = str(price)
+ fields[4] = "100"
+ fields[5] = "0.15"
+ fields[12] = str(volume)
+ fields[13] = str(volume * 10)
+ return "^".join(fields)
+
+
+def _handler():
+ handler = WebSocketHandler(
+ config=SimpleNamespace(kis_api={"app_key": "", "app_secret": "", "use_mock": True})
+ )
+ received = []
+ handler.on_price_update(received.append)
+ return handler, received
+
+
+def _feed(handler, frame):
+ asyncio.run(handler._handle_message(frame))
+
+
+def test_multi_record_frame_emits_every_record():
+ handler, received = _handler()
+ frame = "0|H0STCNT0|002|" + _record("005930", 70000, 10) + "^" + _record("000660", 150000, 3)
+
+ _feed(handler, frame)
+
+ assert [(d["symbol"], d["price"], d["volume"]) for d in received] == [
+ ("005930", 70000.0, 10),
+ ("000660", 150000.0, 3),
+ ]
+ assert set(handler._price_cache) == {"005930", "000660"}
+
+
+def test_single_record_frame_is_unchanged():
+ handler, received = _handler()
+ _feed(handler, "0|H0STCNT0|001|" + _record("005930", 70000, 10))
+ assert [(d["symbol"], d["price"]) for d in received] == [("005930", 70000.0)]
+
+
+def test_trailing_separator_does_not_break_split():
+ handler, received = _handler()
+ frame = "0|H0STCNT0|002|" + _record("005930", 70000, 10) + "^" + _record("035420", 200000, 5) + "^"
+ _feed(handler, frame)
+ assert [d["symbol"] for d in received] == ["005930", "035420"]
+
+
+def test_uneven_frame_falls_back_to_first_record_with_warning():
+ handler, received = _handler()
+ messages = []
+ sink = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING")
+ try:
+ frame = "0|H0STCNT0|002|" + _record("005930", 70000, 10) + "^extra"
+ _feed(handler, frame)
+ finally:
+ logger.remove(sink)
+
+ assert [d["symbol"] for d in received] == ["005930"]
+ assert any("나누어떨어지지 않음" in m for m in messages)
+
+
+def _runtime_sources():
+ yield ROOT / "main.py"
+ for folder in ("core", "monitoring", "tools"):
+ yield from (ROOT / folder).rglob("*.py")
+
+
+def test_project_guide_matches_websocket_wiring_state():
+ """런타임이 핸들러를 시작하지 않는 한 문서는 '미연결'로 표기해야 한다(반대도 마찬가지)."""
+ wired = any(
+ "WebSocketHandler(" in path.read_text(encoding="utf-8", errors="ignore")
+ for path in _runtime_sources()
+ )
+ guide = (ROOT / "docs" / "PROJECT_GUIDE.md").read_text(encoding="utf-8")
+
+ if wired:
+ pytest.fail(
+ "WebSocketHandler가 런타임에 연결됐다 — docs/PROJECT_GUIDE.md의 '런타임 미연결' "
+ "표기를 실제 연결 상태로 갱신하고 이 테스트를 고치세요."
+ )
+ assert "✅ **WebSocket 갭 상태/보충 처리**" not in guide
+ assert "런타임 미연결" in guide
diff --git a/tests/test_audit_schema.py b/tests/test_audit_schema.py
new file mode 100644
index 00000000..64c9bc9d
--- /dev/null
+++ b/tests/test_audit_schema.py
@@ -0,0 +1,71 @@
+"""모델 ↔ 실제 스키마 대조 (2026-09-23).
+
+create_all은 이미 있는 테이블을 고치지 않는다 — 모델의 제약을 바꿔도 예전 DB에는 옛 제약이
+남는다. 7/10 kr_pocket 가짜 낙폭이 이 경우였다(positions의 옛 UNIQUE(symbol)).
+"""
+
+from sqlalchemy import create_engine, text
+
+from database.models import Base, _repair_schema_drift, check_schema_drift
+
+
+def _engine(tmp_path):
+ engine = create_engine(f"sqlite:///{tmp_path / 'drift.db'}")
+ Base.metadata.create_all(engine)
+ return engine
+
+
+def test_fresh_schema_has_no_drift(tmp_path):
+ assert check_schema_drift(_engine(tmp_path)) == []
+
+
+def test_legacy_unique_and_missing_index_are_reported(tmp_path):
+ engine = _engine(tmp_path)
+ with engine.connect() as conn:
+ conn.execute(text("DROP INDEX IF EXISTS ix_trade_history_account_key"))
+ conn.execute(text('DROP TABLE "daily_reports"'))
+ conn.execute(text(
+ 'CREATE TABLE daily_reports (id INTEGER PRIMARY KEY, account_key VARCHAR(64), '
+ 'date DATETIME, UNIQUE(date))'
+ ))
+ conn.commit()
+
+ issues = check_schema_drift(engine)
+
+ assert any("trade_history" in i and "ix_trade_history_account_key" in i for i in issues)
+ assert any("daily_reports" in i and "옛 UNIQUE" in i for i in issues)
+
+
+def test_repair_fixes_safe_drift_without_losing_rows(tmp_path):
+ engine = _engine(tmp_path)
+ with engine.connect() as conn:
+ conn.execute(text("DROP INDEX IF EXISTS ix_trade_history_account_key"))
+ conn.execute(text(
+ "INSERT INTO operation_events (event_type, severity, message, mode, created_at) "
+ "VALUES ('X', 'info', 'm', 'paper', '2026-09-23')"
+ ))
+ conn.commit()
+
+ _repair_schema_drift(engine)
+
+ assert check_schema_drift(engine) == []
+ with engine.connect() as conn:
+ assert conn.execute(text("SELECT COUNT(*) FROM operation_events")).scalar() == 1
+
+
+def test_non_empty_legacy_daily_reports_is_left_for_operator(tmp_path):
+ engine = _engine(tmp_path)
+ with engine.connect() as conn:
+ conn.execute(text('DROP TABLE "daily_reports"'))
+ conn.execute(text(
+ 'CREATE TABLE daily_reports (id INTEGER PRIMARY KEY, account_key VARCHAR(64), '
+ 'date DATETIME, UNIQUE(date))'
+ ))
+ conn.execute(text("INSERT INTO daily_reports (account_key, date) VALUES ('', '2026-09-01')"))
+ conn.commit()
+
+ _repair_schema_drift(engine)
+
+ with engine.connect() as conn:
+ assert conn.execute(text("SELECT COUNT(*) FROM daily_reports")).scalar() == 1
+ assert any("옛 UNIQUE" in i for i in check_schema_drift(engine))
diff --git a/tests/test_audit_strategies_breakout.py b/tests/test_audit_strategies_breakout.py
new file mode 100644
index 00000000..b45f02c2
--- /dev/null
+++ b/tests/test_audit_strategies_breakout.py
@@ -0,0 +1,100 @@
+"""breakout_volume 청산 기준 감사 회귀 테스트.
+
+예전 청산은 매 봉의 breakout_ref(직전 N봉 고가 최대)와 비교했다. T+1의 breakout_ref는
+돌파봉 T의 고가까지 포함하므로, 돌파 레벨을 다시 깨지 않았는데도 진입 다음 봉에
+SELL이 나는 1일 왕복이 대부분이었다. 이제 진입 봉에서 돌파한 레벨(entry_level)을 고정한다.
+"""
+
+import numpy as np
+import pandas as pd
+
+from config.config_loader import Config
+
+
+def _strategy(*, real_indicators=False):
+ from strategies.breakout_volume import BreakoutVolumeStrategy
+
+ strategy = BreakoutVolumeStrategy(Config.get())
+ strategy.params = {"breakout_period": 10, "surge_ratio": 1.5, "adx_min": 20}
+ if not real_indicators:
+ # ADX는 항상 추세 있음(30)으로 두고 돌파·거래량 조건만 본다.
+ strategy.indicator_engine.calculate_all = lambda df: df.assign(adx=30.0)
+ return strategy
+
+
+def _scenario():
+ """0~14 횡보(고가 101) → 15 돌파(종가 105, 고가 108, 거래량 3배) → 이후 되밀림."""
+ closes = [100.0] * 15 + [105.0, 103.0, 100.0, 99.0, 102.0]
+ highs = [101.0] * 15 + [108.0, 104.0, 101.0, 100.0, 103.0]
+ volumes = [1000.0] * 15 + [3000.0, 1000.0, 1000.0, 1000.0, 1000.0]
+ dates = pd.bdate_range("2024-01-01", periods=len(closes))
+ return pd.DataFrame(
+ {
+ "open": closes,
+ "high": highs,
+ "low": [c - 1 for c in closes],
+ "close": closes,
+ "volume": volumes,
+ },
+ index=dates,
+ )
+
+
+def test_next_bar_above_broken_level_is_not_a_sell():
+ df = _scenario()
+ out = _strategy().analyze(df)
+ t = 15
+ assert out["signal"].iloc[t] == "BUY"
+ # T+1 종가 103은 돌파봉 고가(108)보다 낮지만 돌파한 레벨(101)보다 위 → 실패 아님
+ assert out["breakout_ref"].iloc[t + 1] == 108.0
+ assert out["signal"].iloc[t + 1] != "SELL"
+ assert out["entry_level"].iloc[t + 1] == 101.0
+
+
+def test_close_below_broken_level_sells_every_bar_until_recovered():
+ df = _scenario()
+ out = _strategy().analyze(df)
+ # 17·18: 종가가 101 아래 → 매 봉 SELL (최소 보유일에 막혀도 다음 봉에 다시 나온다)
+ assert out["signal"].iloc[17] == "SELL"
+ assert out["signal"].iloc[18] == "SELL"
+ # 19: 102로 레벨 위 회복 → SELL 없음
+ assert out["signal"].iloc[19] != "SELL"
+ # 진입 전에는 청산 기준 레벨이 없다
+ assert out["entry_level"].iloc[:15].isna().all()
+ assert "SELL" not in set(out["signal"].iloc[:15])
+
+
+def test_strict_prefix_matches_full_analyze():
+ df = _scenario()
+ strategy = _strategy()
+ full = strategy.analyze(df)
+ for i in range(len(df)):
+ prefix = strategy.analyze(df.iloc[: i + 1].copy())
+ assert prefix["signal"].iloc[-1] == full["signal"].iloc[i], i
+
+
+def test_random_series_never_sells_next_bar_above_broken_level():
+ """실제 지표(ADX 포함)로 돌린 무작위 일봉에서 불변식 확인."""
+ strategy = _strategy(real_indicators=True)
+ buys = 0
+ for seed in range(8):
+ rng = np.random.default_rng(seed)
+ n = 400
+ close = 10_000 * np.exp(np.cumsum(rng.normal(0.0005, 0.02, n)))
+ high = close * (1 + rng.uniform(0, 0.02, n))
+ low = close * (1 - rng.uniform(0, 0.02, n))
+ volume = rng.lognormal(12, 0.6, n)
+ df = pd.DataFrame(
+ {"open": close, "high": high, "low": low, "close": close, "volume": volume},
+ index=pd.bdate_range("2022-01-03", periods=n),
+ )
+ out = strategy.analyze(df)
+ sig = out["signal"].tolist()
+ for t in range(len(sig) - 1):
+ if sig[t] != "BUY":
+ continue
+ buys += 1
+ broken_level = out["breakout_ref"].iloc[t]
+ if sig[t + 1] == "SELL":
+ assert out["close"].iloc[t + 1] < broken_level, (seed, t)
+ assert buys > 0, "시나리오에 진입이 한 번도 없으면 불변식 검사가 무의미하다"
diff --git a/tests/test_audit_strategies_ensemble.py b/tests/test_audit_strategies_ensemble.py
new file mode 100644
index 00000000..088573c9
--- /dev/null
+++ b/tests/test_audit_strategies_ensemble.py
@@ -0,0 +1,159 @@
+"""앙상블 auto_downgrade 감사 회귀 테스트.
+
+예전에는 첫 analyze() 호출의 신호 전체로 상관을 계산해 인스턴스 모드를 영구히 바꿨다.
+포트폴리오 백테스트는 한 인스턴스로 종목마다 전 기간 df를 넘기므로, 첫 종목의 미래
+신호가 모든 날짜·종목의 모드를 정했고 결과가 종목 순서에 따라 달라졌다.
+"""
+
+import numpy as np
+import pandas as pd
+
+from config.config_loader import Config
+
+
+class _FixedSignals:
+ """미리 정한 신호를 날짜에 맞춰 돌려주는 구성 전략."""
+
+ def __init__(self, signals: pd.Series):
+ self.signals = signals
+
+ def analyze(self, df):
+ out = df.copy()
+ out["signal"] = self.signals.reindex(out.index).fillna("HOLD")
+ out["strategy_score"] = 0.0
+ return out
+
+
+def _random_signals(index, seed):
+ rng = np.random.default_rng(seed)
+ return pd.Series(rng.choice(["BUY", "HOLD", "SELL"], size=len(index)), index=index)
+
+
+def _frame(n=200, start="2024-01-01"):
+ idx = pd.bdate_range(start, periods=n)
+ close = np.linspace(100, 120, n)
+ return pd.DataFrame(
+ {"open": close, "high": close, "low": close, "close": close, "volume": 1e6}, index=idx
+ )
+
+
+def _ensemble(components):
+ from core.strategy_ensemble import StrategyEnsemble
+
+ ens = StrategyEnsemble(Config.get())
+ ens.mode = "majority_vote"
+ ens.auto_downgrade = True
+ ens._independence_window = 60
+ ens._independence_threshold = 0.6
+ ens._strategies = [(name, _FixedSignals(sig), 1.0) for name, sig in components]
+ return ens
+
+
+def _correlated_symbol(df):
+ """technical과 momentum이 항상 같은 신호 → 모든 창에서 고상관."""
+ tech = _random_signals(df.index, 1)
+ return [
+ ("technical", tech),
+ ("momentum_factor", tech.copy()),
+ ("volatility_condition", _random_signals(df.index, 3)),
+ ]
+
+
+def _independent_symbol(df):
+ return [
+ ("technical", _random_signals(df.index, 11)),
+ ("momentum_factor", _random_signals(df.index, 12)),
+ ("volatility_condition", _random_signals(df.index, 13)),
+ ]
+
+
+def test_result_does_not_depend_on_symbol_order():
+ df = _frame()
+ corr_parts = _correlated_symbol(df)
+ ind_parts = _independent_symbol(df)
+
+ # 종목 순서 A → B
+ ens = _ensemble(corr_parts)
+ a_first = ens.analyze(df)
+ ens._strategies = [(n, _FixedSignals(s), 1.0) for n, s in ind_parts]
+ b_second = ens.analyze(df)
+
+ # 종목 순서 B → A (새 인스턴스)
+ ens2 = _ensemble(ind_parts)
+ b_first = ens2.analyze(df)
+ ens2._strategies = [(n, _FixedSignals(s), 1.0) for n, s in corr_parts]
+ a_second = ens2.analyze(df)
+
+ pd.testing.assert_series_equal(a_first["signal"], a_second["signal"])
+ pd.testing.assert_series_equal(b_first["signal"], b_second["signal"])
+ # 설정 모드는 바뀌지 않는다
+ assert ens.mode == "majority_vote" and ens2.mode == "majority_vote"
+
+
+def test_mode_uses_only_trailing_window():
+ """고상관 구간이 뒤에만 있으면 앞 구간은 설정 모드 그대로 (미래 신호 미참조)."""
+ df = _frame(n=240)
+ tech = _random_signals(df.index, 21)
+ mom = _random_signals(df.index, 22)
+ mom.iloc[120:] = tech.iloc[120:] # 120행부터 technical과 같은 신호
+ ens = _ensemble([
+ ("technical", tech),
+ ("momentum_factor", mom),
+ ("volatility_condition", _random_signals(df.index, 23)),
+ ])
+ out = ens.analyze(df)
+ assert (out["ensemble_mode"].iloc[:120] == "majority_vote").all()
+ # 창 전체가 동일 신호 구간(179행 이후)이면 반드시 conservative
+ assert (out["ensemble_mode"].iloc[180:] == "conservative").all()
+
+
+def test_strict_prefix_matches_full_run():
+ df = _frame(n=160)
+ tech = _random_signals(df.index, 31)
+ mom = _random_signals(df.index, 32)
+ mom.iloc[80:] = tech.iloc[80:]
+ parts = [
+ ("technical", tech),
+ ("momentum_factor", mom),
+ ("volatility_condition", _random_signals(df.index, 33)),
+ ]
+ full = _ensemble(parts).analyze(df)
+ ens = _ensemble(parts)
+ for i in range(len(df)):
+ last = ens.analyze(df.iloc[: i + 1]).iloc[-1]
+ assert last["signal"] == full["signal"].iloc[i], i
+ assert last["ensemble_mode"] == full["ensemble_mode"].iloc[i], i
+
+
+def test_constant_component_is_not_treated_as_correlated():
+ """창 안에서 신호가 변하지 않는 구성(데이터 없는 펀더멘털 등)은 상관 판정에서 빠진다."""
+ df = _frame(n=150)
+ ens = _ensemble([
+ ("technical", _random_signals(df.index, 41)),
+ ("momentum_factor", _random_signals(df.index, 42)),
+ ("fundamental_factor", pd.Series("HOLD", index=df.index)),
+ ])
+ out = ens.analyze(df)
+ assert (out["ensemble_mode"] == "majority_vote").all()
+
+
+def test_check_failure_is_logged_at_warning(monkeypatch):
+ from loguru import logger
+
+ df = _frame(n=100)
+ ens = _ensemble(_correlated_symbol(df))
+
+ def boom(_df):
+ raise RuntimeError("corr failed")
+
+ monkeypatch.setattr(ens, "_trailing_high_correlation", boom)
+ messages = []
+ sink_id = logger.add(lambda m: messages.append(str(m)), level="WARNING")
+ try:
+ out = ens.analyze(df)
+ ens.analyze(df)
+ finally:
+ logger.remove(sink_id)
+ assert (out["ensemble_mode"] == "majority_vote").all()
+ hits = [m for m in messages if "앙상블 독립성 검사 실패" in m]
+ assert len(hits) == 1 and "corr failed" in hits[0]
diff --git a/tests/test_audit_strategies_index_cache.py b/tests/test_audit_strategies_index_cache.py
new file mode 100644
index 00000000..32c8c8c1
--- /dev/null
+++ b/tests/test_audit_strategies_index_cache.py
@@ -0,0 +1,248 @@
+"""지수 캐시 감사 회귀 테스트 — RSR 시장 필터.
+
+예전 시장 필터는 첫 analyze() 호출의 날짜 범위로 KS11을 한 번만 받아 캐시했다.
+strict 백테스트의 첫 호출은 df.iloc[:1]이라 1일치 상태가 전 기간에 ffill되어 필터가
+한 번도 작동하지 않았고, 조회 실패 시에는 경고만 남기고 필터 없이 돌았다.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from config.config_loader import Config
+
+
+def _ks11_series():
+ """2018~2021 영업일 KS11: 상승 → 2020년 중반 급락 → 회복 (SMA200 이탈 구간 생성)."""
+ dates = pd.bdate_range("2018-01-02", "2021-12-30")
+ n = len(dates)
+ t = np.arange(n)
+ level = 2000 + 1.2 * t
+ crash = (t > 600) & (t < 760)
+ level = np.where(crash, level - 6.0 * (t - 600), level)
+ level = np.where(t >= 760, level - 6.0 * 160 + 4.0 * (t - 760), level)
+ return pd.Series(level.astype(float), index=dates)
+
+
+class _FakeCollector:
+ """요청 구간만 잘라 주는 가짜 수집기. 호출 횟수를 센다."""
+
+ calls: list = []
+ fail = False
+
+ def __init__(self):
+ self.quiet_ohlcv_log = False
+
+ def fetch_korean_stock(self, symbol, start_date=None, end_date=None):
+ type(self).calls.append((symbol, start_date, end_date))
+ if type(self).fail:
+ raise ConnectionError("rate limited")
+ s = _ks11_series().loc[pd.Timestamp(start_date): pd.Timestamp(end_date)]
+ return pd.DataFrame(
+ {"open": s, "high": s, "low": s, "close": s, "volume": 1.0}, index=s.index
+ )
+
+
+@pytest.fixture
+def fake_collector(monkeypatch):
+ import core.data_collector as dc
+
+ _FakeCollector.calls = []
+ _FakeCollector.fail = False
+ monkeypatch.setattr(dc, "DataCollector", _FakeCollector)
+ return _FakeCollector
+
+
+def _stock_frame(start="2019-06-03", n=400):
+ dates = pd.bdate_range(start, periods=n)
+ close = np.linspace(100, 160, n)
+ return pd.DataFrame(
+ {"open": close, "high": close + 1, "low": close - 1, "close": close, "volume": 1e6},
+ index=dates,
+ )
+
+
+def _rotation(**params):
+ from strategies.relative_strength_rotation import RelativeStrengthRotationStrategy
+
+ strategy = RelativeStrengthRotationStrategy(Config.get())
+ strategy.params = {
+ "short_lookback": 2,
+ "long_lookback": 3,
+ "sma_period": 2,
+ "short_weight": 0.6,
+ "market_filter_sma200": True,
+ "market_filter_exit": True,
+ "market_filter_ma_period": 200,
+ **params,
+ }
+ strategy.indicator_engine.calculate_all = lambda df: df
+ return strategy
+
+
+def test_strict_prefix_market_filter_matches_full_run(fake_collector):
+ df = _stock_frame()
+ full = _rotation().analyze(df)
+ assert (~full["market_filter_pass"]).sum() > 20, "시나리오에 필터 차단 구간이 있어야 한다"
+ assert full["market_filter_active"].all()
+
+ strategy = _rotation()
+ fake_collector.calls = []
+ for i in range(len(df)):
+ last = strategy.analyze(df.iloc[: i + 1].copy()).iloc[-1]
+ assert bool(last["market_filter_pass"]) == bool(full["market_filter_pass"].iloc[i]), i
+ assert bool(last["market_filter_exit"]) == bool(full["market_filter_exit"].iloc[i]), i
+ # 봉마다가 아니라 인스턴스당 한 번만 받는다
+ assert len(fake_collector.calls) == 1
+
+
+def test_market_filter_value_uses_only_previous_day(fake_collector):
+ """날짜 d의 통과 여부는 d-1 종가·SMA로 정해진다 (넓게 받아도 미래 정보 없음)."""
+ df = _stock_frame()
+ out = _rotation().analyze(df)
+ ks = _ks11_series()
+ sma = ks.rolling(200, min_periods=200).mean()
+ expected_prev = (ks > sma).shift(1)
+ for d in df.index[::17]:
+ assert bool(out.loc[d, "market_filter_pass"]) == bool(expected_prev.loc[d]), d
+
+
+def test_load_failure_is_recorded_and_not_retried_per_bar(fake_collector):
+ fake_collector.fail = True
+ df = _stock_frame(n=60)
+ strategy = _rotation()
+ for i in range(len(df)):
+ out = strategy.analyze(df.iloc[: i + 1].copy())
+ assert len(fake_collector.calls) == 1
+ assert not out["market_filter_active"].any()
+ assert out["market_filter_pass"].all()
+ assert strategy.market_filter_status["ok"] is False
+ assert "rate limited" in strategy.market_filter_status["reason"]
+ res = strategy.generate_signal(df)
+ assert res["details"]["market_filter_active"] is False
+
+
+def test_sma_warmup_days_are_unknown_not_bearish(fake_collector):
+ """SMA가 아직 없는 날은 '아래'(차단·강제청산)가 아니라 판단 불가로 둔다."""
+ df = _stock_frame(start="2018-01-02", n=260)
+ out = _rotation().analyze(df)
+ head = out.iloc[:150]
+ assert not head["market_filter_active"].any()
+ assert head["market_filter_pass"].all()
+ assert not head["market_filter_exit"].any()
+ assert out["market_filter_active"].iloc[-1]
+
+
+def test_earlier_symbol_refetches_union_span(fake_collector):
+ strategy = _rotation()
+ strategy.analyze(_stock_frame(start="2020-01-02", n=200))
+ assert len(fake_collector.calls) == 1
+ # 같은 인스턴스로 더 이른 기간의 종목을 분석 → 구간을 합쳐 한 번 더 받는다
+ out = strategy.analyze(_stock_frame(start="2019-03-04", n=200))
+ assert len(fake_collector.calls) == 2
+ assert out["market_filter_active"].all()
+
+
+def test_index_cache_refetches_when_request_passes_cached_end(fake_collector):
+ from strategies.index_cache import IndexCloseCache
+
+ cache = IndexCloseCache("KS11", warmup_days=10)
+ cache.ensure("2020-01-02", "2020-06-30")
+ cache.ensure("2020-02-03", "2020-05-29") # 캐시 안 → 재조회 없음
+ assert cache.fetch_count == 1
+ future = pd.Timestamp.today().normalize() + pd.Timedelta(days=30)
+ cache.ensure("2020-01-02", future) # 날짜가 넘어간 장기 실행 → 재조회
+ assert cache.fetch_count == 2
+
+
+# ── 벤치마크 상대 모멘텀·회전: 봉마다 지수를 다시 받지 않는다 ──
+
+
+def _momentum(**params):
+ from strategies.momentum_factor import MomentumFactorStrategy
+
+ strategy = MomentumFactorStrategy(Config.get())
+ strategy.params = {
+ "benchmark_relative": True,
+ "benchmark_symbol": "KS11",
+ "lookback_days": 20,
+ "buy_threshold_pct": 2.0,
+ "sell_threshold_pct": -2.0,
+ **params,
+ }
+ return strategy
+
+
+def _wavy_stock(start="2019-06-03", n=260):
+ dates = pd.bdate_range(start, periods=n)
+ t = np.arange(n)
+ close = 100 + 10 * np.sin(t / 15.0) + 0.05 * t
+ return pd.DataFrame(
+ {"open": close, "high": close + 1, "low": close - 1, "close": close, "volume": 1e6},
+ index=dates,
+ )
+
+
+def test_momentum_benchmark_fetched_once_in_strict_loop(fake_collector):
+ df = _wavy_stock()
+ full = _momentum().analyze(df)
+ strategy = _momentum()
+ fake_collector.calls = []
+ # 1행짜리 접두는 analyze가 계산 없이 HOLD로 돌려주므로 2행부터 본다.
+ for i in range(1, len(df)):
+ last = strategy.analyze(df.iloc[: i + 1].copy()).iloc[-1]
+ assert last["signal"] == full["signal"].iloc[i], i
+ if pd.notna(full["benchmark_return"].iloc[i]):
+ assert np.isclose(last["benchmark_return"], full["benchmark_return"].iloc[i]), i
+ assert len(fake_collector.calls) == 1
+ # 캐시가 봉마다 늘어나지 않는다 (예전에는 봉마다 시리즈가 하나씩 쌓였다)
+ assert len(strategy._benchmark_return_cache) == 1
+
+
+def test_momentum_benchmark_values_are_point_in_time(fake_collector):
+ df = _wavy_stock()
+ out = _momentum().analyze(df)
+ ks = _ks11_series()
+ expected = (ks / ks.shift(20) - 1) * 100
+ for d in df.index[::13]:
+ assert np.isclose(out.loc[d, "benchmark_return"], expected.loc[d]), d
+ assert (out["signal"] == "BUY").any() and (out["signal"] == "SELL").any()
+
+
+def test_momentum_benchmark_failure_is_not_retried_per_bar(fake_collector):
+ fake_collector.fail = True
+ df = _wavy_stock(n=60)
+ strategy = _momentum()
+ for i in range(len(df)):
+ out = strategy.analyze(df.iloc[: i + 1].copy())
+ assert len(fake_collector.calls) == 1
+ assert out["benchmark_return"].isna().all()
+ assert "BUY" not in set(out["signal"])
+
+
+def test_rotation_benchmark_composite_fetched_once_in_strict_loop(fake_collector):
+ df = _wavy_stock(n=200)
+ params = {
+ "market_filter_sma200": False,
+ "score_mode": "benchmark_excess",
+ "rank_entry_mode": "dense_ranked",
+ "use_positive_momentum_filter": False,
+ "use_trend_filter": False,
+ "exit_trend_edge": False,
+ "exit_rebalance_mode": "none",
+ }
+ full = _rotation(**params).analyze(df)
+ strategy = _rotation(**params)
+ fake_collector.calls = []
+ for i in range(len(df)):
+ last = strategy.analyze(df.iloc[: i + 1].copy()).iloc[-1]
+ assert last["signal"] == full["signal"].iloc[i], i
+ expected = full["benchmark_composite_score"].iloc[i]
+ if pd.notna(expected):
+ assert np.isclose(last["benchmark_composite_score"], expected), i
+ assert len(fake_collector.calls) == 1
+
+ ks = _ks11_series()
+ expected_composite = 0.6 * ks.pct_change(2) + 0.4 * ks.pct_change(3)
+ d = df.index[100]
+ assert np.isclose(full.loc[d, "benchmark_composite_score"], expected_composite.loc[d])
diff --git a/tests/test_audit_strategies_mean_reversion.py b/tests/test_audit_strategies_mean_reversion.py
new file mode 100644
index 00000000..da3b3b67
--- /dev/null
+++ b/tests/test_audit_strategies_mean_reversion.py
@@ -0,0 +1,154 @@
+"""mean_reversion 백테스트/paper 매수 규칙 일치 감사 회귀 테스트.
+
+예전에는 52주 필터가 generate_signal()에만 있어 백테스트(analyze만 호출)가 필터 없는
+다른 전략을 평가했다. 이제 두 52주 필터는 analyze()에서 적용된다. 펀더멘털 필터와
+코스피200 제한은 '현재' 데이터만 있어 백테스트에 넣으면 미래 정보가 섞이므로
+analyze()에 넣지 않고 '반영 안 됨'으로 기록한다.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from config.config_loader import Config
+
+_BASE_PARAMS = {
+ "z_score_buy": -2.0,
+ "z_score_sell": 2.0,
+ "lookback_period": 20,
+ "adx_filter": 20,
+ "volume_spike_filter": 3.0,
+ "exclude_52w_low_near": True,
+ "max_drawdown_from_52w_high": 0.30,
+ "near_52w_low_pct": 0.05,
+ "window_52w": 252,
+ "restrict_to_kospi200": False,
+ "fundamental_filter": {"enabled": False},
+}
+
+
+def _strategy(**overrides):
+ from strategies.mean_reversion import MeanReversionStrategy
+
+ strategy = MeanReversionStrategy(Config.get())
+ strategy.params = {**_BASE_PARAMS, **overrides}
+ # 지표는 고정: ADX 15(<20), RSI 30(<40), 거래량 정상 → Z-Score와 52주 필터만 신호를 가른다.
+ strategy.indicator_engine.calculate_all = lambda df: df.assign(
+ adx=15.0, rsi=30.0, volume_ratio=1.0
+ )
+ return strategy
+
+
+def _frame(closes):
+ closes = np.asarray(closes, dtype=float)
+ return pd.DataFrame(
+ {
+ "open": closes,
+ "high": closes + 1,
+ "low": closes - 1,
+ "close": closes,
+ "volume": [1_000_000.0] * len(closes),
+ },
+ index=pd.bdate_range("2023-01-02", periods=len(closes)),
+ )
+
+
+def _near_low_case():
+ # 100 부근 횡보 뒤 마지막 봉 90으로 급락: 52주 저점(89) 대비 +1.1% → 신저가 근방
+ return _frame([100.0 + (i % 3) * 0.5 for i in range(60)] + [90.0])
+
+
+def _drawdown_case():
+ # 150 → 80 하락 → 105 회복 횡보 → 마지막 100: 52주 고점(151) 대비 -33.8%, 저점 대비 +26%
+ down = list(np.linspace(150, 80, 40))
+ up = list(np.linspace(80, 105, 20))
+ flat = [105.0 + (i % 2) * 0.3 for i in range(25)]
+ return _frame(down + up + flat + [100.0])
+
+
+def _clean_case():
+ # 80 → 110 상승 → 105 횡보 → 마지막 100: 고점 대비 -9.9%, 저점 대비 +26% → 필터 통과
+ up = list(np.linspace(80, 110, 40))
+ flat = [105.0 + (i % 2) * 0.3 for i in range(25)]
+ return _frame(up + flat + [100.0])
+
+
+@pytest.mark.parametrize(
+ "case, expected, veto_col",
+ [
+ (_near_low_case, "HOLD", "buy_veto_52w_near_low"),
+ (_drawdown_case, "HOLD", "buy_veto_52w_drawdown"),
+ (_clean_case, "BUY", None),
+ ],
+)
+def test_analyze_matches_generate_signal_on_last_bar(case, expected, veto_col):
+ strategy = _strategy()
+ df = case()
+ analyzed = strategy.analyze(df)
+ assert analyzed["z_score"].iloc[-1] <= -2.0, "시나리오 전제: 마지막 봉이 Z-Score 매수 영역"
+ assert analyzed["signal"].iloc[-1] == expected
+ assert strategy.generate_signal(df)["signal"] == expected
+ if veto_col:
+ assert bool(analyzed[veto_col].iloc[-1]) is True
+
+
+def test_52w_filter_off_restores_raw_zscore_buy():
+ strategy = _strategy(exclude_52w_low_near=False)
+ analyzed = strategy.analyze(_near_low_case())
+ assert analyzed["signal"].iloc[-1] == "BUY"
+ assert not analyzed["buy_veto_52w_near_low"].any()
+
+
+def test_prefix_parity_between_backtest_path_and_generate_signal():
+ """모든 시점에서 analyze(접두) 마지막 신호 == generate_signal(접두) 신호."""
+ rng = np.random.default_rng(11)
+ closes = 100 * np.exp(np.cumsum(rng.normal(-0.002, 0.03, 200)))
+ df = _frame(closes)
+ strategy = _strategy()
+ full = strategy.analyze(df)
+ assert (full["signal"] == "BUY").any(), "시나리오에 매수 신호가 있어야 한다"
+ assert (full["buy_veto_52w_near_low"] | full["buy_veto_52w_drawdown"]).any(), (
+ "시나리오에 52주 필터로 걸러진 매수가 있어야 한다"
+ )
+ for i in range(25, len(df)):
+ prefix = df.iloc[: i + 1]
+ bt_signal = strategy.analyze(prefix)["signal"].iloc[-1]
+ assert bt_signal == full["signal"].iloc[i], i
+ assert strategy.generate_signal(prefix)["signal"] == bt_signal, i
+
+
+def test_backtest_path_never_calls_point_in_time_unsafe_filters(monkeypatch):
+ """펀더멘털 필터·코스피200 조회는 현재 데이터라 analyze()가 부르면 안 된다."""
+ import strategies.mean_reversion as mr
+
+ def _boom(*args, **kwargs):
+ raise AssertionError("analyze()가 현재 시점 펀더멘털을 조회함 (미래 정보)")
+
+ monkeypatch.setattr(mr, "check_fundamental_filter", _boom)
+ strategy = _strategy(
+ restrict_to_kospi200=True,
+ fundamental_filter={"enabled": True, "per_min": 0, "per_max": 50},
+ )
+ strategy._is_kospi200 = _boom
+ analyzed = strategy.analyze(_clean_case())
+ assert analyzed["signal"].iloc[-1] == "BUY"
+ assert strategy.unmodelled_backtest_filters() == [
+ "restrict_to_kospi200",
+ "fundamental_filter",
+ ]
+
+
+def test_unmodelled_filters_warn_once_per_instance():
+ from loguru import logger
+
+ messages = []
+ sink_id = logger.add(lambda m: messages.append(str(m)), level="WARNING")
+ try:
+ strategy = _strategy(fundamental_filter={"enabled": True, "per_min": 0})
+ strategy.analyze(_clean_case())
+ strategy.analyze(_clean_case())
+ finally:
+ logger.remove(sink_id)
+ hits = [m for m in messages if "반영하지 않습니다" in m]
+ assert len(hits) == 1, messages
+ assert "fundamental_filter" in hits[0]
diff --git a/tests/test_audit_strategies_regime.py b/tests/test_audit_strategies_regime.py
new file mode 100644
index 00000000..17dbd521
--- /dev/null
+++ b/tests/test_audit_strategies_regime.py
@@ -0,0 +1,169 @@
+"""시장 국면 모듈 감사 회귀 테스트."""
+
+from types import SimpleNamespace
+
+import pandas as pd
+import pytest
+
+
+# ── regime_adaptive: 계산만 되고 읽히지 않던 buy_threshold_offset 제거 ──
+
+
+def _regime_cfg(regime_adaptive):
+ return SimpleNamespace(
+ trading={"market_regime_filter": False},
+ risk_params={},
+ strategies={"regime_adaptive": regime_adaptive},
+ )
+
+
+@pytest.mark.parametrize(
+ "regime, sl, tp",
+ [("bullish", 1.0, 1.0), ("caution", 0.7, 0.8), ("bearish", 0.5, 0.6)],
+)
+def test_regime_adjusted_params_only_carry_sl_tp_multipliers(monkeypatch, regime, sl, tp):
+ import core.market_regime as mr
+ from config.config_loader import load_strategies
+
+ monkeypatch.setattr(
+ mr,
+ "check_market_regime",
+ lambda config, collector=None: {
+ "regime": regime,
+ "allow_buys": regime != "bearish",
+ "position_scale": {"bullish": 1.0, "caution": 0.5, "bearish": 0.0}[regime],
+ "details": {},
+ },
+ )
+ cfg = _regime_cfg(load_strategies()["regime_adaptive"])
+ out = mr.get_regime_adjusted_params(cfg)
+ assert "buy_threshold_offset" not in out
+ assert out["stop_loss_multiplier"] == sl
+ assert out["take_profit_multiplier"] == tp
+
+
+def test_strategies_yaml_has_no_dead_buy_threshold_offset():
+ from config.config_loader import load_strategies
+
+ ra = load_strategies()["regime_adaptive"]
+ for regime in ("bullish", "caution", "bearish"):
+ assert "buy_threshold_offset" not in ra[regime], regime
+
+
+# ── check_market_regime: 거래일 수를 달력일로 환산, 오늘 봉 제외, 마지막 MA 유한성 ──
+
+
+def _runtime_cfg(ma_days=200):
+ return SimpleNamespace(
+ strategies={},
+ risk_params={},
+ trading={
+ "market_regime_filter": True,
+ "market_regime_index": "KS11",
+ "market_regime_ma_days": ma_days,
+ "market_regime_short_momentum_days": 20,
+ "market_regime_short_momentum_threshold": -5.0,
+ "market_regime_caution_scale": 0.5,
+ "market_regime_ma_cross_enabled": False,
+ },
+ )
+
+
+def _krx_days(start, end):
+ """요청 구간의 KRX 거래일 (주말·config/holidays.yaml 휴장일 제외)."""
+ from core.trading_hours import _load_holidays
+
+ holidays = {pd.Timestamp(h) for h in _load_holidays()}
+ days = pd.bdate_range(pd.Timestamp(start), pd.Timestamp(end))
+ return pd.DatetimeIndex([d for d in days if d not in holidays])
+
+
+def _today():
+ from datetime import datetime
+ from zoneinfo import ZoneInfo
+
+ return pd.Timestamp(datetime.now(ZoneInfo("Asia/Seoul")).date())
+
+
+class _CalendarCollector:
+ """요청 구간 안의 실제 거래일만 돌려주는 가짜 수집기 (예전 테스트 스텁은 N행 고정)."""
+
+ def __init__(self, level_fn, *, drop_last_sessions=0, extra_today_close=None):
+ self.level_fn = level_fn
+ self.drop_last_sessions = drop_last_sessions
+ self.extra_today_close = extra_today_close
+ self.requests = []
+
+ def fetch_korean_stock(self, symbol, start_date=None, end_date=None):
+ self.requests.append((start_date, end_date))
+ days = _krx_days(start_date, end_date)
+ days = days[days < _today()]
+ if self.drop_last_sessions:
+ days = days[: -self.drop_last_sessions]
+ closes = [self.level_fn(i) for i in range(len(days))]
+ if self.extra_today_close is not None:
+ days = days.append(pd.DatetimeIndex([_today()]))
+ closes.append(self.extra_today_close)
+ return pd.DataFrame({"close": closes}, index=days)
+
+
+def test_enabled_regime_gets_enough_bars_for_ma200():
+ from core.market_regime import check_market_regime
+
+ rising = _CalendarCollector(lambda i: 1000.0 + i)
+ result = check_market_regime(_runtime_cfg(), rising)
+ assert result["regime"] == "bullish", result
+ assert result["allow_buys"] is True
+ start, end = rising.requests[0]
+ assert len(_krx_days(start, end)) >= 250 # MA200 + 여유
+
+ falling = _CalendarCollector(lambda i: 3000.0 - 5.0 * i)
+ result = check_market_regime(_runtime_cfg(), falling)
+ assert result["regime"] == "bearish", result
+ assert result["details"]["below_ma"] is True
+
+
+def test_intraday_bar_dated_today_is_not_used():
+ from core.market_regime import check_market_regime
+
+ # 오늘 날짜 봉이 폭락(1.0)이어도 확정 봉(어제까지)으로만 판정한다.
+ collector = _CalendarCollector(lambda i: 1000.0 + i, extra_today_close=1.0)
+ result = check_market_regime(_runtime_cfg(), collector)
+ assert result["regime"] == "bullish", result
+ assert result["details"]["last_close"] > 1.0
+ assert pd.Timestamp(result["details"]["last_bar_date"]) < _today()
+
+
+def test_non_finite_last_ma_fails_closed():
+ from core.market_regime import check_market_regime
+
+ collector = _CalendarCollector(lambda i: float("inf") if i > 150 else 1000.0 + i)
+ result = check_market_regime(_runtime_cfg(), collector)
+ assert result["regime"] == "unknown"
+ assert result["allow_buys"] is False
+ assert result["details"]["reason"] == "ma_unavailable"
+
+
+def test_stale_index_feed_is_reported_in_details():
+ from core.market_regime import check_market_regime
+
+ collector = _CalendarCollector(lambda i: 1000.0 + i, drop_last_sessions=3)
+ result = check_market_regime(_runtime_cfg(), collector)
+ assert result["regime"] == "bullish"
+ assert result["details"]["missing_sessions"] == 3
+
+
+def test_disabled_filter_keeps_default_without_fetch():
+ from core.market_regime import check_market_regime
+
+ cfg = _runtime_cfg()
+ cfg.trading["market_regime_filter"] = False
+ collector = _CalendarCollector(lambda i: 1000.0 + i)
+ result = check_market_regime(cfg, collector)
+ assert result == {
+ "regime": "bullish",
+ "position_scale": 1.0,
+ "allow_buys": True,
+ "details": {},
+ }
+ assert collector.requests == []
diff --git a/tests/test_audit_strategies_scoring.py b/tests/test_audit_strategies_scoring.py
new file mode 100644
index 00000000..24cb0b50
--- /dev/null
+++ b/tests/test_audit_strategies_scoring.py
@@ -0,0 +1,239 @@
+"""스코어링 신호 감사 회귀 테스트 — MACD 워밍업 NaN 처리.
+
+예전 _score_macd는 NaN 비교(False)를 'Signal 아래'로 읽어, 시그널선이 나오기 전
+워밍업 구간 전체를 -1로 채점하고 첫 유효 봉을 가짜 골든크로스(+2)로 만들었다.
+"""
+
+import numpy as np
+import pandas as pd
+
+from core.signal_generator import SignalGenerator
+
+
+_WEIGHTS = {
+ "rsi_oversold": 2,
+ "rsi_overbought": -2,
+ "macd_golden_cross": 2,
+ "macd_dead_cross": -2,
+ "bollinger_lower": 1,
+ "bollinger_upper": -1,
+ "volume_surge": 1,
+ "ma_golden_cross": 1,
+ "ma_dead_cross": -1,
+}
+
+
+class _Cfg:
+ """운영 설정과 같은 모드(representative_only + 히스터리시스)의 최소 설정."""
+
+ def __init__(self, *, mode="representative_only", indicators=None):
+ self.strategies = {
+ "scoring": {
+ "buy_threshold": 2,
+ "sell_threshold": -2,
+ "collinearity_mode": mode,
+ "hysteresis": {
+ "enabled": True,
+ "exit_sell_threshold": -1,
+ "exit_buy_threshold": 0.5,
+ },
+ "weights": dict(_WEIGHTS),
+ }
+ }
+ self.indicators = indicators or {
+ "rsi": {"oversold": 30, "overbought": 70},
+ "volume": {"surge_ratio": 1.5},
+ "moving_average": {"short_period": 5, "mid_period": 20},
+ }
+
+
+def _indicator_frame(n=120, seed=7):
+ """실제 IndicatorEngine(pandas-ta)으로 지표를 붙인 무작위 일봉."""
+ from config.config_loader import Config
+ from core.indicator_engine import IndicatorEngine
+
+ rng = np.random.default_rng(seed)
+ close = 10_000 * np.exp(np.cumsum(rng.normal(0, 0.02, n)))
+ dates = pd.bdate_range("2024-01-02", periods=n)
+ df = pd.DataFrame(
+ {
+ "open": close,
+ "high": close * 1.01,
+ "low": close * 0.99,
+ "close": close,
+ "volume": rng.integers(100_000, 1_000_000, n).astype(float),
+ },
+ index=dates,
+ )
+ return IndicatorEngine(Config.get()).calculate_all(df)
+
+
+class TestMacdWarmup:
+ def test_warmup_rows_score_zero_and_first_valid_row_is_not_a_cross(self):
+ gen = SignalGenerator(_Cfg())
+ for seed in range(20):
+ df = _indicator_frame(seed=seed)
+ first_valid = df["macd_signal"].first_valid_index()
+ assert first_valid is not None
+ pos = df.index.get_loc(first_valid)
+ assert pos > 0, "시그널선 워밍업이 있어야 하는 시나리오"
+
+ scores = gen._score_macd(df)
+ assert (scores.iloc[:pos] == 0.0).all(), (
+ f"seed={seed}: 워밍업 구간에 MACD 점수가 들어감 {scores.iloc[:pos].unique()}"
+ )
+ # 첫 유효 봉은 비교할 전일이 없으므로 크로스(±2)가 아니라 유지 점수(±1)다.
+ assert abs(scores.iloc[pos]) == 1.0, (
+ f"seed={seed}: 첫 유효 봉 점수 {scores.iloc[pos]} (크로스로 잡히면 안 됨)"
+ )
+
+ def test_cross_still_detected_between_two_valid_rows(self):
+ gen = SignalGenerator(_Cfg())
+ dates = pd.bdate_range("2024-01-01", periods=6)
+ df = pd.DataFrame(
+ {
+ "macd": [np.nan, np.nan, -1.0, -0.5, 1.0, -1.0],
+ "macd_signal": [np.nan, np.nan, 0.0, 0.0, 0.0, 0.0],
+ "close": [100.0] * 6,
+ },
+ index=dates,
+ )
+ scores = gen._score_macd(df)
+ assert scores.tolist() == [0.0, 0.0, -1.0, -1.0, 2.0, -2.0]
+
+ def test_histogram_bonus_not_applied_on_invalid_rows(self):
+ gen = SignalGenerator(_Cfg())
+ dates = pd.bdate_range("2024-01-01", periods=4)
+ # 히스토그램만 값이 있고 시그널선이 비어 있는 행은 여전히 0점이어야 한다.
+ df = pd.DataFrame(
+ {
+ "macd": [1.0, 2.0, 3.0, 4.0],
+ "macd_signal": [np.nan, np.nan, 1.0, 1.0],
+ "macd_histogram": [0.5, 1.0, 2.0, 3.0],
+ "close": [100.0] * 4,
+ },
+ index=dates,
+ )
+ scores = gen._score_macd(df)
+ assert scores.iloc[0] == 0.0
+ assert scores.iloc[1] == 0.0
+ # 2행: 첫 유효 봉(유지 +1) + 히스토그램 상승 보너스 +0.5
+ assert scores.iloc[2] == 1.5
+
+ def test_warmup_does_not_carry_sell_state_into_first_valid_bar(self):
+ """워밍업 -1과 거래량 하락일 -1이 합쳐져 SELL 상태가 이월되던 경로."""
+ gen = SignalGenerator(_Cfg())
+ n = 12
+ dates = pd.bdate_range("2024-01-01", periods=n)
+ macd = [np.nan] * 8 + [1.0, 1.2, 1.3, 1.4]
+ signal = [np.nan] * 8 + [0.5, 0.5, 0.5, 0.5]
+ close = [100.0] * n
+ close[3] = 95.0 # 하락일
+ volume_ratio = [1.0] * n
+ volume_ratio[3] = 3.0 # 하락일 거래량 급증 → score_volume -1
+ df = pd.DataFrame(
+ {
+ "macd": macd,
+ "macd_signal": signal,
+ "close": close,
+ "volume_ratio": volume_ratio,
+ },
+ index=dates,
+ )
+ out = gen.generate(df)
+ assert (out["score_macd"].iloc[:8] == 0.0).all()
+ assert "SELL" not in set(out["signal"].iloc[:8]), out["signal"].tolist()
+
+
+# ── MA 점수: 설정 기간의 SMA 컬럼을 정확히 사용 ──
+
+
+def _ma_frame(short_vals, mid_vals, *, short_col="sma_5", mid_col="sma_20", extra=None):
+ n = len(short_vals)
+ dates = pd.bdate_range("2024-01-01", periods=n)
+ data = {short_col: short_vals, mid_col: mid_vals, "close": [100.0] * n}
+ data.update(extra or {})
+ return pd.DataFrame(data, index=dates)
+
+
+class TestMaColumnPick:
+ def test_uses_sma_cross_not_ema_or_sma200(self):
+ """SMA는 크로스하고 EMA·sma_200은 크로스하지 않는 프레임 — SMA 크로스만 채점."""
+ gen = SignalGenerator(_Cfg(mode="max_per_direction"))
+ df = _ma_frame(
+ [9.0, 9.0, 11.0, 11.0, 9.0],
+ [10.0, 10.0, 10.0, 10.0, 10.0],
+ extra={
+ # 예전 로직은 마지막 일치 컬럼(ema_5/ema_20)을 썼다. EMA는 계속 위에 있다.
+ "ema_5": [12.0] * 5,
+ "ema_20": [10.0] * 5,
+ "sma_200": [50.0] * 5,
+ },
+ )
+ # IndicatorEngine과 같은 컬럼 순서(sma → ema)로 둔다.
+ df = df[["sma_5", "sma_20", "sma_200", "ema_5", "ema_20", "close"]]
+ scores = gen._score_ma(df)
+ assert scores.tolist() == [0.0, 0.0, 1.0, 0.0, -1.0]
+
+ def test_reconfigured_periods_use_matching_columns(self):
+ cfg = _Cfg(
+ mode="max_per_direction",
+ indicators={
+ "rsi": {"oversold": 30, "overbought": 70},
+ "moving_average": {"short_period": 10, "mid_period": 30},
+ },
+ )
+ gen = SignalGenerator(cfg)
+ df = _ma_frame(
+ [9.0, 11.0, 11.0, 9.0],
+ [10.0, 10.0, 10.0, 10.0],
+ short_col="sma_10",
+ mid_col="sma_30",
+ )
+ scores = gen._score_ma(df)
+ assert scores.tolist() == [0.0, 1.0, 0.0, -1.0]
+
+ def test_warmup_nan_is_not_a_cross(self):
+ gen = SignalGenerator(_Cfg(mode="max_per_direction"))
+ df = _ma_frame(
+ [9.0, 9.0, 11.0, 11.0, 12.0],
+ [np.nan, np.nan, 10.0, 10.0, 10.0],
+ )
+ scores = gen._score_ma(df)
+ # 2행이 첫 유효 봉: 전일 비교 대상이 없으므로 골든크로스가 아니다.
+ assert scores.tolist() == [0.0, 0.0, 0.0, 0.0, 0.0]
+
+ def test_missing_configured_columns_warns_once_and_scores_zero(self):
+ from loguru import logger
+
+ messages = []
+ sink_id = logger.add(lambda m: messages.append(str(m)), level="WARNING")
+ try:
+ cfg = _Cfg(
+ mode="max_per_direction",
+ indicators={"moving_average": {"short_period": 10, "mid_period": 30}},
+ )
+ gen = SignalGenerator(cfg)
+ df = _ma_frame([9.0, 11.0], [10.0, 10.0]) # sma_5/sma_20만 있음
+ assert gen._score_ma(df).tolist() == [0.0, 0.0]
+ assert gen._score_ma(df).tolist() == [0.0, 0.0]
+ finally:
+ logger.remove(sink_id)
+ hits = [m for m in messages if "MA 점수 컬럼" in m]
+ assert len(hits) == 1, messages
+
+ def test_real_indicator_frame_scores_sma_cross(self):
+ """IndicatorEngine 출력(sma_200 포함)에서도 SMA5/SMA20 크로스와 일치해야 한다."""
+ gen = SignalGenerator(_Cfg(mode="max_per_direction"))
+ df = _indicator_frame(n=260, seed=3)
+ assert "sma_200" in df.columns
+ scores = gen._score_ma(df)
+ above = df["sma_5"] > df["sma_20"]
+ valid = df["sma_5"].notna() & df["sma_20"].notna()
+ prev_valid = valid.shift(1, fill_value=False)
+ expected_golden = valid & prev_valid & above & ~above.shift(1, fill_value=False)
+ expected_dead = valid & prev_valid & ~above & above.shift(1, fill_value=False)
+ assert expected_golden.any() and expected_dead.any()
+ assert (scores[expected_golden] == 1.0).all()
+ assert (scores[expected_dead] == -1.0).all()
+ assert (scores[~(expected_golden | expected_dead)] == 0.0).all()
diff --git a/tests/test_audit_strategies_trend.py b/tests/test_audit_strategies_trend.py
new file mode 100644
index 00000000..7834f80e
--- /dev/null
+++ b/tests/test_audit_strategies_trend.py
@@ -0,0 +1,95 @@
+"""trend_following 최적화 파라미터 감사 회귀 테스트.
+
+예전에는 최적화기가 trend_ma_period·atr_stop_multiplier를 탐색했지만 전략은 sma_200을
+고정으로 읽고 두 키를 어디서도 읽지 않아, 48개 조합 중 서로 다른 결과는 4개뿐이었다.
+"""
+
+import inspect
+
+import numpy as np
+import pandas as pd
+
+from config.config_loader import Config
+
+
+def _trending_frame(n=400, seed=5):
+ rng = np.random.default_rng(seed)
+ # 상승 → 하락 → 상승 구간을 섞어 추세선 기간에 따라 위/아래 판정이 갈리게 한다.
+ drift = np.concatenate([
+ np.full(n // 3, 0.003),
+ np.full(n // 3, -0.003),
+ np.full(n - 2 * (n // 3), 0.003),
+ ])
+ close = 10_000 * np.exp(np.cumsum(drift + rng.normal(0, 0.012, n)))
+ return pd.DataFrame(
+ {
+ "open": close,
+ "high": close * 1.01,
+ "low": close * 0.99,
+ "close": close,
+ "volume": rng.integers(100_000, 1_000_000, n).astype(float),
+ },
+ index=pd.bdate_range("2022-01-03", periods=n),
+ )
+
+
+def _strategy(**overrides):
+ from strategies import create_strategy
+
+ config = Config.get()
+ if overrides:
+ config = config.with_strategy_overrides("trend_following", overrides)
+ return create_strategy("trend_following", config)
+
+
+def test_trend_ma_period_changes_signals():
+ df = _trending_frame()
+ fast = _strategy(trend_ma_period=60).analyze(df)
+ slow = _strategy(trend_ma_period=200).analyze(df)
+ assert not fast["strategy_score"].equals(slow["strategy_score"])
+ assert (fast["signal"] != slow["signal"]).any()
+ # 60일 추세선은 60봉째부터 값이 있다
+ assert fast["trend_ma"].first_valid_index() == df.index[59]
+
+
+def test_default_period_matches_previous_sma200_behaviour():
+ """기본값 200은 IndicatorEngine의 sma_200과 같은 선이다 — 기존 결과 불변."""
+ df = _trending_frame()
+ out = _strategy().analyze(df)
+ assert "sma_200" in out.columns
+ pd.testing.assert_series_equal(
+ out["trend_ma"], out["sma_200"], check_names=False
+ )
+
+
+def test_generate_signal_length_gate_follows_period():
+ df = _trending_frame(n=120)
+ res = _strategy(trend_ma_period=60).generate_signal(df)
+ assert "데이터 부족" not in str(res["details"].get("이유", ""))
+ assert "60일선" in res["details"]
+ res200 = _strategy(trend_ma_period=200).generate_signal(df)
+ assert res200["details"]["이유"] == "데이터 부족(200일 필요)"
+
+
+def test_every_trend_following_search_key_changes_analyze_output():
+ """최적화 탐색 공간의 모든 키가 실제로 전략 출력에 영향을 줘야 한다."""
+ from backtest.param_optimizer import DEFAULT_SEARCH_SPACES
+
+ df = _trending_frame()
+ space = DEFAULT_SEARCH_SPACES["trend_following"]
+ for key, values in space.items():
+ low = _strategy(**{key: min(values)}).analyze(df)
+ high = _strategy(**{key: max(values)}).analyze(df)
+ assert not low["strategy_score"].equals(high["strategy_score"]), (
+ f"{key}={min(values)} vs {max(values)} 결과가 같음 — 읽히지 않는 탐색 키"
+ )
+
+
+def test_dead_stop_multiplier_keys_removed_from_optimizer():
+ import backtest.param_optimizer as opt
+
+ assert "atr_stop_multiplier" not in opt.DEFAULT_SEARCH_SPACES["trend_following"]
+ # bayesian_optimize 안의 default_bounds(지역 dict)도 같은 키를 탐색하지 않아야 한다.
+ source = inspect.getsource(opt)
+ assert '"atr_stop_multiplier"' not in source
+ assert '"trailing_atr_multiplier"' not in source
diff --git a/tests/test_basket_evaluation.py b/tests/test_basket_evaluation.py
index fe432d0b..989fce88 100644
--- a/tests/test_basket_evaluation.py
+++ b/tests/test_basket_evaluation.py
@@ -52,14 +52,14 @@ def test_wait_still_surfaces_integrity_issues():
"""기간 전이라도 커버리지 붕괴·실패주문은 이슈로 보여준다(판정은 WAIT 유지)."""
out = _eval(trading_days_total=10, snapshot_days=5, pending_failed_orders=2)
assert out["verdict"] == "WAIT"
- assert any("커버리지" in i for i in out["issues"])
+ assert any("제때 남긴 스냅샷" in i for i in out["issues"])
assert any("실패 주문" in i for i in out["issues"])
def test_low_snapshot_coverage_fails_after_period():
out = _eval(snapshot_days=50) # 50/60 = 83% < 95%
assert out["verdict"] == "FAIL_REVIEW"
- assert any("커버리지" in i for i in out["issues"])
+ assert any("제때 남긴 스냅샷" in i for i in out["issues"])
def test_pending_failed_orders_fail_after_period():
diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py
index 59d7bd77..0afc1acc 100644
--- a/tests/test_basket_overlay_integration.py
+++ b/tests/test_basket_overlay_integration.py
@@ -121,7 +121,9 @@ def test_missing_index_data_keeps_previous_state_and_flags(self):
with _patch_snapshots(rb):
decision = rb.overlay_decision()
assert decision.scale == pytest.approx(0.5)
- assert decision.data_issues and "부족" in decision.data_issues[0]
+ # 자료가 아예 없으면 '기간 부족'이 아니라 '쓸 수 없음'으로 적는다 — 오래된 자료로
+ # 입력이 비었는데 '200일치 부족'이라고 쓰면 원인을 엉뚱한 데서 찾게 된다(2026-09-23).
+ assert decision.data_issues and "쓸 수 없음" in decision.data_issues[0]
def test_missing_last_close_cannot_look_like_a_trend_recovery(self):
save_overlay_state("t", OverlayDecision(scale=0.5, trend_below=True))
diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py
index 5b380503..31346fc8 100644
--- a/tests/test_basket_rebalancer.py
+++ b/tests/test_basket_rebalancer.py
@@ -652,6 +652,7 @@ def test_execute_passes_avg_volume_to_buy(self, rebalancer, monkeypatch):
rebalancer.portfolio_mgr = SimpleNamespace(
get_available_cash=MagicMock(return_value=5_000_000),
get_current_capital=MagicMock(return_value=100_000_000),
+ get_portfolio_summary=MagicMock(return_value={"total_value": 95_000_000}),
)
rebalancer._market_snapshot = {
"005930": {"price": 70_000.0, "avg_volume": 123_456.0},
@@ -670,6 +671,80 @@ def test_execute_passes_avg_volume_to_buy(self, rebalancer, monkeypatch):
kwargs = fake_executor.execute_buy_quantity.call_args.kwargs
assert kwargs["avg_daily_volume"] == pytest.approx(123_456.0)
+ def test_execute_sizes_exposure_at_market_prices(self, rebalancer, monkeypatch):
+ """계획은 시가로 하는데 주문 단계가 원가로 재면 하락장 보충 매수가 '투자 비중
+ 초과'로 거부된다(2026-09-23 점검). 가격 스냅샷이 있으면 총자산과 보유 노출을
+ 같은 시가로 넘긴다."""
+ from core.basket_rebalancer import RebalanceOrder
+
+ summary = MagicMock(return_value={"total_value": 95_000_000})
+ rebalancer.portfolio_mgr = SimpleNamespace(
+ get_available_cash=MagicMock(return_value=5_000_000),
+ get_current_capital=MagicMock(return_value=100_000_000),
+ get_portfolio_summary=summary,
+ )
+ rebalancer._market_snapshot = {"005930": {"price": 70_000.0, "avg_volume": 1.0}}
+ fake_executor = MagicMock()
+ fake_executor.execute_buy_quantity.return_value = {"success": True}
+ monkeypatch.setattr(
+ "core.order_executor.OrderExecutor", MagicMock(return_value=fake_executor)
+ )
+
+ rebalancer.execute([RebalanceOrder("005930", "BUY", 10, 70_000, "테스트")])
+
+ kwargs = fake_executor.execute_buy_quantity.call_args.kwargs
+ assert kwargs["capital"] == 95_000_000 # 원가 총자산(1억)이 아니라 시가
+ assert kwargs["mark_prices"] == {"005930": 70_000.0}
+ assert summary.call_args.kwargs["current_prices"] == {"005930": 70_000.0}
+ rebalancer.portfolio_mgr.get_current_capital.assert_not_called()
+
+ def test_failed_order_reason_is_logged_and_returned(self, rebalancer, monkeypatch):
+ from core.basket_rebalancer import RebalanceOrder
+
+ rebalancer.portfolio_mgr = SimpleNamespace(
+ get_available_cash=MagicMock(return_value=5_000_000),
+ get_current_capital=MagicMock(return_value=100_000_000),
+ get_portfolio_summary=MagicMock(return_value={"total_value": 100_000_000}),
+ )
+ rebalancer._market_snapshot = {"005930": {"price": 70_000.0, "avg_volume": 1.0}}
+ fake_executor = MagicMock()
+ fake_executor.execute_buy_quantity.return_value = {
+ "success": False, "reason": "전체 투자 비중 63% 초과",
+ }
+ monkeypatch.setattr(
+ "core.order_executor.OrderExecutor", MagicMock(return_value=fake_executor)
+ )
+
+ result = rebalancer.execute([RebalanceOrder("005930", "BUY", 10, 70_000, "테스트")])
+
+ assert result["failed"] == 1
+ assert result["details"][0]["reason"] == "전체 투자 비중 63% 초과"
+
+ def test_stop_loss_exit_is_sent_as_emergency_but_take_profit_is_not(
+ self, rebalancer, monkeypatch,
+ ):
+ from core.basket_rebalancer import RebalanceOrder
+
+ rebalancer.portfolio_mgr = SimpleNamespace(
+ get_available_cash=MagicMock(return_value=0),
+ get_current_capital=MagicMock(return_value=100_000_000),
+ )
+ rebalancer._market_snapshot = {}
+ fake_executor = MagicMock()
+ fake_executor.execute_sell.return_value = {"success": True}
+ monkeypatch.setattr(
+ "core.order_executor.OrderExecutor", MagicMock(return_value=fake_executor)
+ )
+
+ rebalancer.execute([
+ RebalanceOrder("005930", "SELL", 1, 70_000, "RISK_EXIT STOP_LOSS: 손절"),
+ RebalanceOrder("000660", "SELL", 1, 70_000, "RISK_EXIT TAKE_PROFIT: 익절"),
+ RebalanceOrder("035720", "SELL", 1, 70_000, "비중 초과 (14.9% → 11.1%)"),
+ ])
+
+ flags = [c.kwargs["emergency"] for c in fake_executor.execute_sell.call_args_list]
+ assert flags == [True, False, False]
+
class TestBasketAccountIsolation:
"""여러 enabled 바스켓의 계좌(자본 풀) 공유 차단 — 과배분·트랙레코드 오염 방지."""
diff --git a/tests/test_calendar_audit.py b/tests/test_calendar_audit.py
new file mode 100644
index 00000000..53c157c1
--- /dev/null
+++ b/tests/test_calendar_audit.py
@@ -0,0 +1,130 @@
+"""휴장일 달력 회귀 테스트 (2026-09-23 점검).
+
+달력 오류는 양방향으로 조용히 해롭다.
+- 휴장일 누락 → 커버리지 분모가 부풀고 가짜 결측 경보가 난다(2026-07-17 사례).
+- 거래일을 휴장으로 → 그날 스냅샷이 직전 거래일 행을 덮어써 결측이 숨는다
+ (2026-09-28 사례 — 미래 날짜라 시장 데이터 대조 도구로는 잡을 수 없었다).
+그래서 연도별 '평일 휴장' 집합을 손으로 검증한 값으로 고정한다. 달력을 고치려면
+이 목록도 함께 고쳐야 CI를 통과한다.
+"""
+
+from datetime import date, datetime
+from pathlib import Path
+
+import yaml
+
+from core import holidays_updater as hu
+
+_YAML = Path(__file__).resolve().parent.parent / "config" / "holidays.yaml"
+
+# 월력요항 기준으로 손 검증한 평일 휴장일(주말에 걸린 공휴일은 판정에 영향이 없어 뺐다)
+VERIFIED_WEEKDAY_CLOSURES = {
+ 2026: {
+ "2026-01-01", "2026-02-16", "2026-02-17", "2026-02-18", "2026-03-02",
+ "2026-05-01", "2026-05-05", "2026-05-25", "2026-06-03", "2026-07-17",
+ "2026-08-17", "2026-09-24", "2026-09-25", "2026-10-05", "2026-10-09",
+ "2026-12-25", "2026-12-31",
+ },
+ 2027: {
+ "2027-01-01", "2027-02-08", "2027-02-09", "2027-03-01", "2027-05-05",
+ "2027-05-13", "2027-08-16", "2027-09-14", "2027-09-15", "2027-09-16",
+ "2027-10-04", "2027-10-11", "2027-12-27", "2027-12-31",
+ },
+}
+
+
+def _weekday_set(dates, year):
+ out = set()
+ for d in dates:
+ s = str(d)[:10]
+ if int(s[:4]) == year and date.fromisoformat(s).weekday() < 5:
+ out.add(s)
+ return out
+
+
+def _yaml_dates():
+ with open(_YAML, encoding="utf-8") as f:
+ return (yaml.safe_load(f) or {}).get("holidays", [])
+
+
+def test_yaml_weekday_closures_match_verified_list():
+ dates = _yaml_dates()
+ for year, expected in VERIFIED_WEEKDAY_CLOSURES.items():
+ assert _weekday_set(dates, year) == expected, f"{year}년 평일 휴장 집합이 검증 목록과 다름"
+
+
+def test_fallback_weekday_closures_match_yaml():
+ """fallback 표가 고치기 전 값으로 남아 있으면 자동 갱신이 고친 달력을 되돌린다."""
+ dates = _yaml_dates()
+ for year in VERIFIED_WEEKDAY_CLOSURES:
+ assert _weekday_set(hu.FALLBACK_BY_YEAR[year], year) == _weekday_set(dates, year)
+
+
+def test_chuseok_2026_saturday_overlap_has_no_substitute():
+ from core.trading_hours import TradingHours
+
+ th = TradingHours()
+ assert th.is_trading_day(datetime(2026, 9, 24)) is False
+ assert th.is_trading_day(datetime(2026, 9, 25)) is False
+ assert th.is_trading_day(datetime(2026, 9, 28)) is True
+
+
+# --------------------------------------------------------------- 자동 갱신 안전성
+
+def _write(path, dates, header="# 교정 이력 주석\n# 두 번째 줄\n"):
+ path.write_text(header + "\n" + yaml.safe_dump({"holidays": sorted(dates)}), encoding="utf-8")
+
+
+def test_fallback_update_never_removes_existing_entries(tmp_path, monkeypatch):
+ """pykrx가 실패하면 fallback이 사람이 고친 파일을 덮어쓰지 못해야 한다."""
+ path = tmp_path / "holidays.yaml"
+ _write(path, {"2026-07-17", "2026-09-24"})
+ monkeypatch.setattr(hu, "_fetch_from_pykrx", lambda a, b: (set(), None))
+ monkeypatch.setattr(hu, "FALLBACK_BY_YEAR", {2026: {"2026-01-01"}})
+
+ hu.update_holidays_yaml(path=path, year_from=2026, year_to=2026)
+
+ saved = set(str(d) for d in yaml.safe_load(path.read_text(encoding="utf-8"))["holidays"])
+ assert {"2026-07-17", "2026-09-24", "2026-01-01"} <= saved
+
+
+def test_update_preserves_header_comments(tmp_path, monkeypatch):
+ path = tmp_path / "holidays.yaml"
+ _write(path, {"2026-09-24"})
+ monkeypatch.setattr(hu, "_fetch_from_pykrx", lambda a, b: (set(), None))
+
+ hu.update_holidays_yaml(path=path, year_from=2026, year_to=2026)
+
+ text = path.read_text(encoding="utf-8")
+ assert text.startswith("# 교정 이력 주석\n# 두 번째 줄\n")
+
+
+def test_pykrx_verified_range_is_authoritative_but_future_is_kept(tmp_path, monkeypatch):
+ """시장 데이터로 확인된 구간은 바로잡고, 확인되지 않은 미래 구간은 건드리지 않는다."""
+ path = tmp_path / "holidays.yaml"
+ # 01-27은 실제로는 개장한 날(틀린 항목), 09-24는 아직 확인 불가한 미래
+ _write(path, {"2026-01-27", "2026-09-24", "2026-03-01"})
+ monkeypatch.setattr(
+ hu, "_fetch_from_pykrx", lambda a, b: ({"2026-02-16"}, date(2026, 9, 22)),
+ )
+ monkeypatch.setattr(hu, "FALLBACK_BY_YEAR", {2026: {"2026-09-25"}})
+
+ hu.update_holidays_yaml(path=path, year_from=2026, year_to=2026)
+
+ saved = set(str(d) for d in yaml.safe_load(path.read_text(encoding="utf-8"))["holidays"])
+ assert "2026-01-27" not in saved # 확인 구간의 틀린 평일 항목은 바로잡힌다
+ assert "2026-02-16" in saved # 확인 구간의 실제 휴장
+ assert "2026-03-01" in saved # 주말 항목은 그대로
+ assert {"2026-09-24", "2026-09-25"} <= saved # 미래 구간은 기존 ∪ fallback
+
+
+def test_pykrx_derivation_stops_at_last_returned_session(monkeypatch):
+ """pykrx에는 미래 영업일이 없다 — 마지막 거래일 뒤 평일을 휴장으로 찍으면 안 된다."""
+ trading = {date(2026, 9, 21), date(2026, 9, 22), date(2026, 9, 23)}
+ monkeypatch.setattr(hu, "_pykrx_trading_dates", lambda s, e: trading)
+
+ holidays, verified = hu._fetch_from_pykrx(2026, 2026)
+
+ assert verified == date(2026, 9, 23)
+ assert all(d <= "2026-09-23" for d in holidays)
+ assert "2026-09-24" not in holidays and "2026-12-01" not in holidays
diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py
index bbb0a507..b5228f82 100644
--- a/tests/test_critical_fixes.py
+++ b/tests/test_critical_fixes.py
@@ -190,6 +190,8 @@ def save_daily_nav_snapshot(self):
notifier = SimpleNamespace(send_message=MagicMock())
monkeypatch.setattr(main_mod.Config, "get", lambda: config)
monkeypatch.setattr(main_mod, "_check_live_readiness_gate", gate)
+ # 휴장일에 테스트가 돌아도 매매 경로를 타도록 거래일로 고정
+ monkeypatch.setattr(main_mod, "_market_closed_today", lambda config, now: False)
monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer)
monkeypatch.setattr("core.notifier.Notifier", lambda cfg: notifier)
monkeypatch.setenv("ENABLE_LIVE_TRADING", "true")
@@ -1264,12 +1266,17 @@ def fake_fetch(self, symbol, start_date=None, end_date=None):
frame["close"] = frame["close"] * 0.95
return frame
- def fake_run(df, *, strategy_name, strict_lookahead=True):
+ def fake_run(df, *, strategy_name, strict_lookahead=True, trade_start_date=None):
run_calls.append(
{
"strategy_name": strategy_name,
"strict_lookahead": strict_lookahead,
"rows": len(df),
+ "warmup_rows": (
+ int(df.index.searchsorted(pd.Timestamp(trade_start_date)))
+ if trade_start_date is not None
+ else 0
+ ),
}
)
return {
@@ -1314,7 +1321,11 @@ def test_wf_produces_nonzero_windows(self, monkeypatch, tmp_path):
]
assert len(run_calls) == n_total
assert all(call["strict_lookahead"] is True for call in run_calls)
- assert all(call["rows"] == 252 for call in run_calls)
+ # 감사 수정(워크포워드 콜드 스타트): 예전엔 테스트 252행만 넘겨 60~200일 지표가 창
+ # 앞부분 내내 꺼져 있었다. 이제 각 창은 직전 train_days(504행)를 지표 워밍업으로 함께
+ # 넘기고 trade_start_date(테스트 시작일)부터 거래·평가한다.
+ assert all(call["rows"] == 504 + 252 for call in run_calls)
+ assert all(call["warmup_rows"] == 504 for call in run_calls)
assert str(result["report_path"]).startswith(str(tmp_path))
def test_wf_result_has_flat_keys(self, monkeypatch, tmp_path):
diff --git a/tests/test_deployment_ratchet.py b/tests/test_deployment_ratchet.py
index b0f7dde6..19ed709a 100644
--- a/tests/test_deployment_ratchet.py
+++ b/tests/test_deployment_ratchet.py
@@ -413,3 +413,62 @@ def test_deployment_monitoring_is_not_disabled():
assert float(tol) < 1.0, (
"%s: 배치율 감시가 사실상 꺼져 있다(tolerance=%s)" % (name, tol)
)
+
+
+# ------------------------------------------------------------- 방어 자산 보충 (2026-09-23)
+
+def _pocket(wired, positions, total_value, cash):
+ holdings = {"069500": 0.5, "357870": 0.5}
+ rb = _rebalancer(holdings=holdings, target_stock_weight=0.95, min_trade=50_000,
+ positions=positions, turnover=0.6)
+ rb.basket["overlays"] = {"defensive_symbol": "357870"}
+ rb._risk_params = {"diversification": {"min_cash_ratio": 0.05}}
+ rb.basket["min_cash_ratio"] = 0.05
+ wired(rb, total_value)
+ rb.portfolio_mgr.get_portfolio_summary.return_value = {
+ "total_value": total_value, "cash": cash,
+ }
+ return rb
+
+
+def test_idle_cash_goes_to_defensive_parking_etf(wired):
+ """kr_pocket 9/23 실제 상태: 지수 1주·파킹 3주·현금 10.4만 원 — 두 종목 다 상한에 막혀
+ 한 달째 현금이 놀았다. 파킹 ETF 1주는 다음 사이클 매도 규칙이 되팔지 않는 범위다."""
+ prices = {"069500": 113_800, "357870": 57_900}
+ positions = [_pos("069500", 122_345, 1), _pos("357870", 57_913, 3)]
+ total = 113_800 + 3 * 57_900 + 103_870
+ rb = _pocket(wired, positions, total, 103_870)
+
+ orders = rb.plan_rebalance(prices)
+
+ buys = [o for o in orders if o.action == "BUY"]
+ assert [(o.symbol, o.quantity) for o in buys] == [("357870", 1)]
+ assert "배치율 보충" in buys[0].reason
+
+
+def test_defensive_exemption_never_applies_to_equity(wired):
+ """주식 종목은 여전히 목표+허용을 넘겨 사지 않는다 — 주식 비중이 설계를 넘는다."""
+ prices = {"069500": 113_800, "357870": 57_900}
+ # 파킹 ETF가 이미 목표 이상이라 보충할 곳은 지수뿐인 상태
+ positions = [_pos("069500", 113_800, 1), _pos("357870", 57_900, 4)]
+ total = 113_800 + 4 * 57_900 + 120_000
+ rb = _pocket(wired, positions, total, 120_000)
+
+ for o in rb.plan_rebalance(prices):
+ if o.action == "BUY" and o.symbol == "069500":
+ investable = total * 0.95
+ projected = (113_800 + o.quantity * o.price) / investable
+ assert projected <= 0.5 + 0.08 + 1e-9
+
+
+def test_defensive_topup_stays_below_sell_back_threshold(wired):
+ """다음 사이클에 되팔릴 만큼(초과분 ≥ max(min_trade, 1주)) 사지는 않는다."""
+ prices = {"069500": 113_800, "357870": 57_900}
+ positions = [_pos("069500", 113_800, 1), _pos("357870", 57_900, 3)]
+ total = 113_800 + 3 * 57_900 + 103_870
+ rb = _pocket(wired, positions, total, 103_870)
+ investable = total * 0.95
+ for o in rb.plan_rebalance(prices):
+ if o.action == "BUY" and o.symbol == "357870":
+ over = (3 + o.quantity) * 57_900 - investable * 0.5
+ assert over < max(50_000, 57_900)
diff --git a/tests/test_integration_smoke.py b/tests/test_integration_smoke.py
index 7ab8de80..99fd24eb 100644
--- a/tests/test_integration_smoke.py
+++ b/tests/test_integration_smoke.py
@@ -111,12 +111,14 @@ def test_backtester_all_strategies(self):
assert key in m
assert "overtrading_warnings" in result
- def test_report_generator(self):
+ def test_report_generator(self, tmp_path):
from backtest.backtester import Backtester
from backtest.report_generator import ReportGenerator
df = _sample_ohlcv(320)
result = Backtester().run(df, strategy_name="scoring")
- rg = ReportGenerator()
+ # 기본 출력 경로(reports/)에 쓰면 실행할 때마다 연구 리포트 폴더에 가짜
+ # backtest_scoring_*.html/txt가 쌓인다 — 임시 폴더로 돌린다.
+ rg = ReportGenerator(output_dir=str(tmp_path))
assert rg.generate_text_report(result) is not None
assert rg.generate_html_report(result) is not None
diff --git a/tests/test_kis_websocket_e2e.py b/tests/test_kis_websocket_e2e.py
index d859fd2b..6828390a 100644
--- a/tests/test_kis_websocket_e2e.py
+++ b/tests/test_kis_websocket_e2e.py
@@ -34,7 +34,14 @@ def _kis_config(self):
orig = dict(config._settings.get("kis_api", {}))
config._settings.setdefault("kis_api", {})
config._settings["kis_api"].update(mock_kis)
+ # 접근 토큰은 이제 (base_url, app_key) 단위로 프로세스 전역 공유된다
+ # (인스턴스별 발급이 1분 1회 발급 한도를 넘겨 live 매수를 막던 결함 수정).
+ # 이 클래스의 테스트들은 같은 키를 쓰므로, 앞 테스트가 발급한 토큰이나
+ # 발급 실패 쿨다운이 다음 테스트로 새지 않게 매 테스트 전후로 비운다.
+ from api.kis_api import reset_shared_token_cache
+ reset_shared_token_cache()
yield
+ reset_shared_token_cache()
config._settings["kis_api"].clear()
config._settings["kis_api"].update(orig)
diff --git a/tests/test_notifier.py b/tests/test_notifier.py
index 86a4ecef..190e8dd4 100644
--- a/tests/test_notifier.py
+++ b/tests/test_notifier.py
@@ -155,6 +155,18 @@ def test_daily_report_omits_absent_v2_fields(monkeypatch):
assert "⚠️ 미체결 슬롯" not in names
+def test_daily_report_shows_dash_when_daily_return_unknown(monkeypatch):
+ """계산하지 못한 일간 수익률(None)은 가짜 0.00%가 아니라 '—'로 나온다."""
+ notifier, _ = _notifier_with_failing_discord(monkeypatch)
+ notifier.send_daily_report({
+ "total_value": 10_000_000, "cash": 2_000_000,
+ "daily_return": None, "cumulative_return": 1.2, "mdd": -3.0,
+ "position_count": 2, "total_trades": 0,
+ })
+ field = next(f for f in notifier.discord.embeds[0]["fields"] if f["name"] == "📈 일일 수익률")
+ assert field["value"] == "—"
+
+
def test_signal_alert_uses_fallback_and_hold_is_silent(monkeypatch):
notifier, emails = _notifier_with_failing_discord(monkeypatch)
diff --git a/tests/test_operator_health.py b/tests/test_operator_health.py
index bb3007e9..3c0f914e 100644
--- a/tests/test_operator_health.py
+++ b/tests/test_operator_health.py
@@ -308,9 +308,16 @@ def test_shortfall_beyond_tolerance_is_attention(self):
def test_within_tolerance_ok(self):
assert self._f(0.76, 0.80)["verdict"] == "OK"
- def test_overdeployment_is_ok(self):
- # 초과 배치는 리밸런서가 자연 교정 — 감시 대상 아님
- assert self._f(0.90, 0.80)["verdict"] == "OK"
+ def test_overdeployment_beyond_tolerance_is_attention(self):
+ # 예전엔 '초과 배치는 리밸런서가 자연 교정'한다고 보고 감시하지 않았다. 그런데
+ # 전 종목이 같이 오르면 종목별 비중은 그대로라 아무것도 팔리지 않는다 —
+ # 60/40이 조용히 70/30이 된다. 현금 래칫의 반대 방향이라 같이 본다(2026-09-23).
+ out = self._f(0.90, 0.80)
+ assert out["verdict"] == "ATTENTION"
+ assert "설계보다 높다" in out["note"]
+
+ def test_small_overdeployment_is_ok(self):
+ assert self._f(0.83, 0.80)["verdict"] == "OK"
def test_none_inputs_ok(self):
assert self._f(None, 0.80)["verdict"] == "OK"
diff --git a/tests/test_paper_runtime.py b/tests/test_paper_runtime.py
index 907e26df..f293d658 100644
--- a/tests/test_paper_runtime.py
+++ b/tests/test_paper_runtime.py
@@ -243,7 +243,8 @@ def test_research_disabled(self, evidence_dir, runtime_dir, tmp_path):
"""approved_strategies.json에서 disabled → research_disabled."""
from core.paper_runtime import get_paper_runtime_state
- approved = Path("reports/approved_strategies.json")
+ import core.paper_runtime as _paper_runtime
+ approved = _paper_runtime.APPROVED_STRATEGIES_PATH
original = None
if approved.exists():
original = approved.read_text(encoding="utf-8")
@@ -283,7 +284,8 @@ def test_corrupt_approved_strategies_fail_closed(self, evidence_dir, registry_pa
_seed_evidence(evidence_dir, strategy, [
{"date": "2026-03-24", "status": "normal", "benchmark_status": "final"},
])
- approved = Path("reports/approved_strategies.json")
+ import core.paper_runtime as _paper_runtime
+ approved = _paper_runtime.APPROVED_STRATEGIES_PATH
original = None
if approved.exists():
original = approved.read_text(encoding="utf-8")
@@ -711,7 +713,8 @@ def test_research_disabled_allows_cleanup(self, evidence_dir, runtime_dir, tmp_p
"""research_disabled + outstanding position → cleanup O."""
from core.paper_runtime import get_paper_runtime_state, is_paper_trade_allowed
- approved = Path("reports/approved_strategies.json")
+ import core.paper_runtime as _paper_runtime
+ approved = _paper_runtime.APPROVED_STRATEGIES_PATH
original = None
if approved.exists():
original = approved.read_text(encoding="utf-8")
diff --git a/tests/test_policy_exposure_limits.py b/tests/test_policy_exposure_limits.py
index 98b30319..61dbcc7d 100644
--- a/tests/test_policy_exposure_limits.py
+++ b/tests/test_policy_exposure_limits.py
@@ -241,3 +241,22 @@ def test_shipped_etf_baskets_are_actually_buyable():
f"{symbol}이 instrument_classes.non_company_symbols에 없다 — "
"업종·실적 필터에 fail-closed로 막힌다"
)
+
+
+def test_defensive_parking_etf_is_capped_by_investment_ratio_only():
+ """방어 자산은 사실상 현금이다 — 계획이 놀고 있는 현금을 파킹 ETF로 옮길 때 주문 단계가
+ 종목 상한(목표+드리프트)으로 막으면 계획과 실행이 어긋난다(2026-09-23)."""
+ basket = {
+ "target_stock_weight": 0.95,
+ "min_cash_ratio": 0.05,
+ "holdings": {"069500": 0.5, "357870": 0.5},
+ "overlays": {"defensive_symbol": "357870"},
+ "rebalance": {"drift_threshold": 0.08, "deployment_band": 0.03},
+ }
+ rb = _rebalancer(basket)
+ rb._overlay_scale = lambda: 1.0
+ parking = rb._policy_exposure_limits("357870")
+ equity = rb._policy_exposure_limits("069500")
+ assert parking["max_position_ratio"] == pytest.approx(0.95)
+ assert equity["max_position_ratio"] == pytest.approx((0.5 + 0.08) * 0.95)
+ assert parking["max_investment_ratio"] == pytest.approx(0.98)
diff --git a/tests/test_rebalance_snapshot.py b/tests/test_rebalance_snapshot.py
index 9a341b71..a9d31c0d 100644
--- a/tests/test_rebalance_snapshot.py
+++ b/tests/test_rebalance_snapshot.py
@@ -38,6 +38,9 @@ def patched_rebalance(monkeypatch):
"core.basket_rebalancer.BasketRebalancer", MagicMock(return_value=fake_rb)
)
monkeypatch.setattr("core.notifier.Notifier", MagicMock())
+ # 휴장일에는 사이클이 매매를 건너뛴다 — 테스트가 실행되는 날(주말·공휴일)에
+ # 결과가 달라지지 않게 거래일로 고정한다. 휴장일 경로는 test_audit_cycle.py.
+ monkeypatch.setattr("main._market_closed_today", lambda config, now: False)
return fake_rb
diff --git a/tests/test_runtime_lock.py b/tests/test_runtime_lock.py
index 9ef14b66..0730e7a6 100644
--- a/tests/test_runtime_lock.py
+++ b/tests/test_runtime_lock.py
@@ -65,9 +65,12 @@ def test_process_runtime_lock_fails_closed_when_lock_file_unavailable(tmp_path):
assert acquired is False
-def test_live_runtime_lock_uses_one_global_path(tmp_path):
+def test_live_runtime_lock_uses_one_global_path(tmp_path, monkeypatch):
from core.runtime_lock import LIVE_RUNTIME_LOCK_FILENAME, live_runtime_lock
+ # conftest가 테스트 전체의 락 위치를 임시 폴더로 돌린다 — 이 테스트는 기본 경로 규칙을 본다
+ monkeypatch.delenv("QUANT_RUNTIME_LOCK_DIR", raising=False)
+
with live_runtime_lock(tmp_path) as first:
assert first is True
assert (tmp_path / "data" / LIVE_RUNTIME_LOCK_FILENAME).exists()
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 6c891794..67c65dd1 100644
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -820,160 +820,13 @@ def __init__(self, config=None, account_key="", *, live_gate_validated=False):
}
-def test_live_scheduler_basket_rebalance_uses_basket_gate_and_scope(monkeypatch):
- """live 장전 바스켓 리밸런싱은 바스켓별 승인 단위로 gate/account/order tag를 맞춘다."""
- from core.scheduler import Scheduler
-
- calls = {}
-
- class FakeRebalancer:
- @staticmethod
- def get_enabled_baskets():
- return ["test_basket"]
-
- def __init__(self, basket_name, config, account_key="", execution_strategy=""):
- calls["basket_name"] = basket_name
- calls["config"] = config
- calls["account_key"] = account_key
- calls["execution_strategy"] = execution_strategy
- self.portfolio_mgr = SimpleNamespace(
- sync_with_broker=MagicMock(return_value={"ok": True, "message": "일치"})
- )
- calls["sync_mock"] = self.portfolio_mgr.sync_with_broker
-
- def should_rebalance(self):
- calls["should_rebalance"] = True
- return True, "드리프트"
-
- def plan_rebalance(self):
- calls["plan_rebalance"] = True
- return ["order"]
-
- def execute(self, orders, dry_run=False, live_confirmed=False):
- calls["execute"] = {
- "orders": orders,
- "dry_run": dry_run,
- "live_confirmed": live_confirmed,
- }
- return {"executed": 1, "skipped": 0, "failed": 0}
-
- gate = MagicMock(return_value=[])
- monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer)
- monkeypatch.setattr("core.scheduler.check_live_readiness_gate", gate)
-
- scheduler = Scheduler(strategy_name="scoring")
- old_mode = scheduler.config.trading.get("mode")
- try:
- scheduler.config.trading["mode"] = "live"
- scheduler._mode = "live"
- scheduler._live_gate_validated = True
- scheduler.discord = MagicMock()
-
- scheduler._run_basket_rebalance_check()
-
- gate.assert_called_once_with(scheduler.config, "basket_rebalance:test_basket")
- assert calls["basket_name"] == "test_basket"
- assert calls["account_key"] == "basket_rebalance:test_basket"
- assert calls["execution_strategy"] == "basket_rebalance:test_basket"
- calls["sync_mock"].assert_called_once_with()
- assert calls["should_rebalance"] is True
- assert calls["plan_rebalance"] is True
- assert calls["execute"] == {
- "orders": ["order"],
- "dry_run": False,
- "live_confirmed": True,
- }
- scheduler.discord.send_message.assert_called_once()
- finally:
- scheduler.config.trading["mode"] = old_mode
-
-
-def test_live_scheduler_basket_rebalance_gate_failure_blocks_before_plan(monkeypatch):
- """live 바스켓 전용 gate가 실패하면 리밸런서 생성과 주문 계획까지 가지 않는다."""
- from core.scheduler import Scheduler
-
- class FakeRebalancer:
- @staticmethod
- def get_enabled_baskets():
- return ["test_basket"]
-
- def __init__(self, *args, **kwargs):
- pytest.fail("live basket gate failure must block before rebalancer init")
-
- gate = MagicMock(return_value=["promotion evidence 부족"])
- monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer)
- monkeypatch.setattr("core.scheduler.check_live_readiness_gate", gate)
-
- scheduler = Scheduler(strategy_name="scoring")
- old_mode = scheduler.config.trading.get("mode")
- try:
- scheduler.config.trading["mode"] = "live"
- scheduler._mode = "live"
- scheduler._live_gate_validated = True
- scheduler.discord = MagicMock()
-
- scheduler._run_basket_rebalance_check()
-
- gate.assert_called_once_with(scheduler.config, "basket_rebalance:test_basket")
- scheduler.discord.send_message.assert_called_once()
- assert scheduler.discord.send_message.call_args.kwargs["critical"] is True
- assert "promotion evidence 부족" in scheduler.discord.send_message.call_args.args[0]
- finally:
- scheduler.config.trading["mode"] = old_mode
-
-
-def test_live_scheduler_basket_rebalance_sync_failure_blocks_orders(monkeypatch):
- """live 바스켓 리밸런싱 전 브로커 동기화가 실패하면 주문 판단과 실행을 생략한다."""
- from core.scheduler import Scheduler
-
- calls = {}
-
- class FakeRebalancer:
- @staticmethod
- def get_enabled_baskets():
- return ["test_basket"]
-
- def __init__(self, basket_name, config, account_key="", execution_strategy=""):
- calls["account_key"] = account_key
- calls["execution_strategy"] = execution_strategy
- self.portfolio_mgr = SimpleNamespace(
- sync_with_broker=MagicMock(
- return_value={"ok": False, "message": "position mismatch"}
- )
- )
-
- def should_rebalance(self):
- pytest.fail("sync failure must block before should_rebalance")
-
- def plan_rebalance(self):
- pytest.fail("sync failure must block before plan_rebalance")
-
- def execute(self, *args, **kwargs):
- pytest.fail("sync failure must block before execute")
-
- monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer)
- monkeypatch.setattr(
- "core.scheduler.check_live_readiness_gate",
- MagicMock(return_value=[]),
- )
-
- scheduler = Scheduler(strategy_name="scoring")
- old_mode = scheduler.config.trading.get("mode")
- try:
- scheduler.config.trading["mode"] = "live"
- scheduler._mode = "live"
- scheduler._live_gate_validated = True
- scheduler.discord = MagicMock()
-
- scheduler._run_basket_rebalance_check()
-
- assert calls["account_key"] == "basket_rebalance:test_basket"
- assert calls["execution_strategy"] == "basket_rebalance:test_basket"
- scheduler.discord.send_message.assert_called_once()
- assert scheduler.discord.send_message.call_args.kwargs["critical"] is True
- assert "position mismatch" in scheduler.discord.send_message.call_args.args[0]
- finally:
- scheduler.config.trading["mode"] = old_mode
+# 스케줄러 장전 바스켓 리밸런싱(_run_basket_rebalance_check)의 gate/계정 범위/동기화
+# 실패 차단을 고정하던 테스트 3건은 삭제했다. 그 경로 자체가 결함이었다(감사
+# runtime-scheduler-basket-premarket-divergent): 08:50~09:00에는 live 주문이 거래 시간
+# 가드에 전부 거부되고 paper는 전일 종가로 체결됐으며, CLI 사이클의 손절·하루 1회
+# 거래 가드·스냅샷 보충이 빠져 있었다. 이제 바스켓은 일일 CLI(main.py --mode rebalance)만
+# 거래하고, 스케줄러가 바스켓을 거래하지 않음은 tests/test_audit_runtime_basket_owner.py가
+# 고정한다. 바스켓 live 게이트·계정 범위는 CLI 경로 테스트(test_basket_live_gate 등)가 다룬다.
def test_scheduler_skips_next_cycle_after_overrun(monkeypatch):
diff --git a/tests/test_snapshot_backfill.py b/tests/test_snapshot_backfill.py
index 3d625b01..73c4bdd3 100644
--- a/tests/test_snapshot_backfill.py
+++ b/tests/test_snapshot_backfill.py
@@ -145,7 +145,7 @@ def test_report_text_shows_the_split(self):
pending_failed_orders=0, total_costs=0, initial_capital=1_000_000,
)
text = format_evaluation_report(r, "t")
- assert "사후 복원" in text and "실측" in text
+ assert "나중에 채운" in text and "제때 기록" in text
def test_measured_row_wins_over_reconstructed_on_upsert(self):
"""나중에 실측이 들어오면 보정 표시를 걷어낸다."""
diff --git a/tests/test_trading_hours.py b/tests/test_trading_hours.py
index c4167bbb..d1ab7de0 100644
--- a/tests/test_trading_hours.py
+++ b/tests/test_trading_hours.py
@@ -40,19 +40,24 @@ def test_2026_substitute_holidays_are_non_trading_days():
트랙레코드 커버리지 분모가 부풀고 스냅샷 귀속(가격 기준일)이 어긋난다.
2026: 3·1절(일)→3/2, 부처님오신날(일)→5/25, 광복절(토)→8/17,
- 추석 토요일 겹침→9/28, 개천절(토)→10/5, KRX 연말 휴장 12/31.
+ 개천절(토)→10/5, KRX 연말 휴장 12/31.
(현충일 6/6(토)은 대체 미적용 — 6/8은 거래일이어야 함)
+ (추석 9/24~26은 토요일과 겹칠 뿐이라 대체 없음 — 9/28은 거래일이어야 함.
+ 설·추석 대체공휴일은 일요일·다른 공휴일 겹침에만 생긴다. 예전에는 이 테스트가
+ 9/28을 휴장으로 고정하고 있었다 — 2026-09-23 수정)
"""
from datetime import datetime
from core.trading_hours import TradingHours
th = TradingHours()
for d in ("2026-03-02", "2026-05-25", "2026-08-17",
- "2026-09-28", "2026-10-05", "2026-12-31"):
+ "2026-10-05", "2026-12-31"):
y, m, dd = map(int, d.split("-"))
assert th.is_trading_day(datetime(y, m, dd)) is False, f"{d}는 휴장일이어야 함"
# 현충일은 대체공휴일 미적용 — 다음 월요일은 정상 거래일
assert th.is_trading_day(datetime(2026, 6, 8)) is True
+ # 추석 연휴의 토요일 겹침은 대체공휴일이 아니다
+ assert th.is_trading_day(datetime(2026, 9, 28)) is True
def test_fallback_sets_are_single_source():
diff --git a/tools/basket_track_record.py b/tools/basket_track_record.py
index 7267d258..46e1f4c5 100644
--- a/tools/basket_track_record.py
+++ b/tools/basket_track_record.py
@@ -40,7 +40,10 @@ def build_track_record(basket_name, start, end, capital=10_000_000):
# 이걸 무시하면 '주식50/현금50' 바스켓이 주식 100%로 시뮬레이션돼
# 수익·MDD가 과대 보고된다(트랙레코드는 운영과 같은 조건이어야 정직하다).
rb = BasketRebalancer(basket_name=basket_name)
- stock_fraction = rb._stock_fraction()
+ # 설계 비중(오버레이 적용 전)을 쓴다. _stock_fraction은 오늘의 오버레이 판단을
+ # 계산해 운영 상태 파일에 저장하므로, 연구용 과거 시뮬레이션이 운영 상태를 바꾸고
+ # 오늘의 배수를 과거 전 구간에 적용하게 된다.
+ stock_fraction = rb.base_stock_fraction()
dc = DataCollector()
closes = {}
diff --git a/tools/risk_overlay_backtest.py b/tools/risk_overlay_backtest.py
index 91fc7601..8ec3ab6a 100644
--- a/tools/risk_overlay_backtest.py
+++ b/tools/risk_overlay_backtest.py
@@ -24,6 +24,8 @@
_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_ROOT))
+# 2026-09-17 표를 만든 10종목(000660 포함). 운영 트랙은 8/07부터 000660을 뺀 9종목이다 —
+# 1주가 자본 대비 커서 보유가 불가능했기 때문. 비교용으로만 남긴다.
BASKET_SYMBOLS = [
"005930",
"000660",
@@ -36,6 +38,16 @@
"012330",
"105560",
]
+
+
+def configured_basket_symbols(name: str = "kr_diversified_hold") -> list[str]:
+ """baskets.yaml에 실제로 설정된 보유 종목. 연구 결론은 운영 트랙이 들고 있는
+ 종목으로 내야 한다 — 하이닉스가 든 10종목 표로 9종목 트랙의 정책을 정하면 안 된다."""
+ import yaml
+
+ with open(_ROOT / "config" / "baskets.yaml", encoding="utf-8") as f:
+ cfg = (yaml.safe_load(f) or {}).get("baskets", {}).get(name) or {}
+ return [str(s) for s in (cfg.get("holdings") or {})]
COMMISSION = 0.00015
SLIPPAGE = 0.0005
STOCK_TAX = 0.0020
@@ -325,14 +337,16 @@ def simulate(
}
-def metrics(frame, extra, years_hint=None):
+def metrics(frame, extra, years_hint=None, rf_annual=RF_ANNUAL):
+ """rf_annual: 샤프의 무위험수익률. 현금이 이자를 받지 않는 트랙(바스켓 paper)은 0으로
+ 잰다 — 시뮬레이션은 현금 0%인데 샤프만 3%를 빼면 같은 흐름이 부당하게 낮아 보인다."""
twr = frame["twr"]
daily = frame["daily_return"]
n_years = (frame.index[-1] - frame.index[0]).days / 365.25
cagr = twr.iloc[-1] ** (1 / n_years) - 1 if n_years > 0 else 0.0
vol = daily.std() * math.sqrt(TRADING_DAYS)
- sharpe = (daily.mean() * TRADING_DAYS - RF_ANNUAL) / vol if vol > 0 else 0.0
+ sharpe = (daily.mean() * TRADING_DAYS - rf_annual) / vol if vol > 0 else 0.0
mdd = frame["drawdown"].min()
calmar = cagr / abs(mdd) if mdd < 0 else float("nan")
yearly = twr.resample("YE").last().pct_change()
@@ -395,39 +409,109 @@ def run_pocket(start="2002-01-01", use_etf=False):
}, frames
-def run_basket(start="2021-12-01"):
- """트랙 2: 대형주 10종목 동일비중을 하나의 위험자산(EW 지수)으로 묶고, 주식 비중 60%."""
+def rebalanced_ew_index(panel, drift_threshold=0.08, cost_rate=None):
+ """동일비중 주식 부분의 지수. 어느 종목이든 목표 비중에서 drift_threshold를 넘게 벗어나면
+ 그날 종가로 동일비중으로 되돌리고, 그 회전에 거래비용을 뺀다(운영 트랙과 같은 규칙).
+
+ 첫날 매수 후 계속 들고만 가면 오른 종목(2023~25의 하이닉스 같은)이 슬리브를 점점
+ 차지해서, 운영 트랙(8%p 벗어나면 되돌림)과 다른 포트폴리오를 재게 된다.
+ """
+ import numpy as np
+ import pandas as pd
+
+ if cost_rate is None:
+ cost_rate = COMMISSION + SLIPPAGE + STOCK_TAX / 2 # 회전의 절반이 매도
+ rets = panel.pct_change().fillna(0.0).to_numpy()
+ n = panel.shape[1]
+ target = np.full(n, 1.0 / n)
+ weights = target.copy()
+ value = 1.0
+ out = [value]
+ for r in rets[1:]:
+ grown = weights * (1 + r)
+ step = grown.sum()
+ value *= step
+ weights = grown / step
+ if np.abs(weights - target).max() > drift_threshold:
+ turnover = np.abs(weights - target).sum()
+ value *= 1 - turnover * cost_rate
+ weights = target.copy()
+ out.append(value)
+ return pd.Series(out, index=panel.index)
+
+
+def run_basket(start="2021-12-01", symbols=None, rf_annual=0.0, target_stock=0.6,
+ policies=None, label="EW"):
+ """트랙 2: 대형주 동일비중(8%p 벗어나면 되돌림) + 이자 없는 현금, 주식 비중 60%.
+
+ symbols 미지정 시 baskets.yaml의 kr_diversified_hold 보유 종목(현재 9종목)을 쓴다.
+ rf_annual=0: 바스켓 paper 계좌의 현금은 이자를 받지 않는다.
+ """
import pandas as pd
- panel = pd.DataFrame({s: _fdr(s, start) for s in BASKET_SYMBOLS}).dropna(how="any")
- ew = (panel / panel.iloc[0]).mean(
- axis=1
- ) # 동일비중 지수 (일별 리밸런싱 근사 없음: 첫날 동일비중 보유)
- # 첫날 동일비중 매수 후 보유한 가치가 정확히 위 식이다 (수량 고정, 가격만 변동).
+ symbols = list(symbols or configured_basket_symbols())
+ panel = pd.DataFrame({s: _fdr(s, start) for s in symbols}).dropna(how="any")
+ ew = rebalanced_ew_index(panel)
index = _fdr("KS200", start)
results = {}
frames = {}
- for policy in POLICIES:
+ for policy in (policies or POLICIES):
frame, extra = simulate(
ew,
policy,
index_closes=index,
- target_stock=0.6,
+ target_stock=target_stock,
initial=10_000_000,
monthly=0.0,
tax_rate=STOCK_TAX,
+ rf_annual=rf_annual,
)
- results[policy.name] = {"label": policy.label, **metrics(frame, extra)}
+ results[policy.name] = {
+ "label": policy.label, **metrics(frame, extra, rf_annual=rf_annual),
+ }
frames[policy.name] = frame
return {
- "symbol": "EW10",
- "symbols": BASKET_SYMBOLS,
+ "symbol": f"{label}{len(symbols)}",
+ "symbols": symbols,
+ "rf_annual": rf_annual,
"start": str(ew.index[0].date()),
"end": str(ew.index[-1].date()),
"results": results,
}, frames
+def run_exposure_review(start="2021-12-01", fractions=(0.6, 0.7, 0.8)):
+ """P3 주식 비중 비교: 같은 9종목을 주식 60/70/80%로 들 때 오를 때·내릴 때 얼마나 따라가는지.
+
+ 국면 분해는 주간 리포트와 같은 정의(core.performance_lens.split_by_regime —
+ KS200 일간 수익률의 부호로 상승일·하락일을 나눔)를 쓴다.
+ """
+ from core.performance_lens import split_by_regime
+
+ static = [p for p in POLICIES if p.name == "static"]
+ index = _fdr("KS200", start)
+ bench_daily = index.pct_change() * 100
+ rows = {}
+ for frac in fractions:
+ summary, frames = run_basket(start, target_stock=frac, policies=static)
+ frame = frames["static"]
+ mine = frame["daily_return"] * 100
+ pairs = [
+ (float(mine.loc[d]), float(bench_daily.loc[d]))
+ for d in mine.index
+ if d in bench_daily.index and bench_daily.loc[d] == bench_daily.loc[d]
+ ]
+ regime = split_by_regime(pairs)
+ rows[f"{int(frac * 100)}%"] = {
+ **summary["results"]["static"],
+ "up_capture": regime["up"].get("capture"),
+ "down_capture": regime["down"].get("capture"),
+ "up_days": regime["up"].get("days"),
+ "down_days": regime["down"].get("days"),
+ }
+ return {"start": start, "symbols": configured_basket_symbols(), "fractions": rows}
+
+
def _plot(track_name, summary, frames, out_png, title):
import matplotlib
@@ -521,7 +605,9 @@ def main():
"--as-of", default=AS_OF, help="이 날짜 미만의 확정 일봉만 사용 (YYYY-MM-DD)"
)
parser.add_argument(
- "--track", choices=["all", "pocket", "pocket_etf", "basket"], default="all"
+ "--track",
+ choices=["all", "pocket", "pocket_etf", "basket", "exposure"],
+ default="all",
)
parser.add_argument("--out-dir", default=str(_ROOT / "reports" / "research"))
parser.add_argument("--image-dir", default=str(_ROOT / "docs" / "images"))
@@ -560,14 +646,35 @@ def main():
if args.track in ("all", "basket"):
summary, frames = run_basket("2021-12-01")
report["basket"] = summary
+ n = len(summary["symbols"])
_plot(
"basket",
summary,
frames,
image_dir / "overlay-basket.png",
- "관찰 트랙 · 대형주 10종목 동일비중 60% + 현금 40% · 2022년 약세장 포함",
+ f"관찰 트랙 · 대형주 {n}종목 동일비중 60% + 현금 40%(이자 없음) · 2022년 약세장 포함",
)
- md += [_markdown("관찰 트랙 (대형주 10종목, 2021-12~)", summary), ""]
+ md += [_markdown(f"관찰 트랙 (지금 들고 있는 {n}종목, 이자 없는 현금, 2021-12~)", summary), ""]
+ # 비교: 2026-09-17 표의 조건(000660 포함 10종목, 현금 연 3%)
+ legacy, _ = run_basket("2021-12-01", symbols=BASKET_SYMBOLS, rf_annual=RF_ANNUAL)
+ report["basket_legacy10"] = legacy
+ md += [_markdown("참고 · 예전 표 조건 (000660 포함 10종목, 현금 연 3%)", legacy), ""]
+ if args.track in ("all", "exposure"):
+ exposure = run_exposure_review("2021-12-01")
+ report["exposure_review"] = exposure
+ md += [
+ "### 주식 비중별 비교 (지금 들고 있는 종목, 고정 비중, 이자 없는 현금, 2021-12~)",
+ "",
+ "| 주식 비중 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 상승 포착 | 하락 포착 |",
+ "|---|---:|---:|---:|---:|---:|",
+ ]
+ for key, r in exposure["fractions"].items():
+ up = f"{r['up_capture'] * 100:.0f}%" if r.get("up_capture") is not None else "—"
+ down = f"{r['down_capture'] * 100:.0f}%" if r.get("down_capture") is not None else "—"
+ md.append(
+ f"| {key} | {r['cagr_pct']:+.2f}% | {r['mdd_pct']:.1f}% | {r['sharpe']:.2f} | {up} | {down} |"
+ )
+ md.append("")
(out_dir / "risk_overlay_backtest.json").write_text(
json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8"
)