-
금액
-
원
-
실제로 입금한 금액과 같게 적습니다.
-
-
5만
-
10만
-
20만
+
+
+ 계좌
+
+
+
+
금액
+
+ 원
+
+
+ 입력한 금액은 모의투자 원금에 더해집니다.
+
+
+
+ 5만
+
+
+ 10만
+
+
+ 20만
+
+
+
+
+ 메모 선택
+
-
- 메모 선택
-
-
-
-
-
아래 내용으로 장부에 기록합니다.
-
-
계좌 —
-
금액 —
-
장부 —
-
-
+
+
계좌와 금액을 확인해 주세요.
+
+
+
계좌
+ —
+
+
+
금액
+ —
+
+
+
구분
+ —
+
+
+
-
-
-
-
+
+
+
+ 다시 입력
+
+
+ 취소
+
+
+ 내용 확인
+
+
+
+
-
-
+
+
+
diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py
index 1412ce0..4728dd6 100644
--- a/monitoring/web_dashboard.py
+++ b/monitoring/web_dashboard.py
@@ -114,7 +114,7 @@ def get_baskets_json() -> dict:
from core.basket_deploy import effective_stock_fraction
from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id
from core.risk_overlays import (
- applied_stock_fraction,
+ overlay_target_weights,
describe_decision,
load_overlay_state,
parse_overlay_config,
@@ -201,16 +201,17 @@ def get_baskets_json() -> dict:
# 곱한 '적용 비중'이 그날의 목표다. 화면의 목표 비중·목표 범위 판정은 적용 비중을 쓴다.
overlay_cfg = parse_overlay_config(basket_config)
overlay_state = load_overlay_state(name) if overlay_cfg.any_enabled else None
- design_fraction = (
- applied_stock_fraction(base_fraction, overlay_state)
- if overlay_cfg.any_enabled
- else base_fraction
+ scale = float((overlay_state or {}).get("scale", 1.0))
+ target_weights = overlay_target_weights(
+ basket_config.get("holdings") or {}, base_fraction, scale,
+ (basket_config.get("overlays") or {}).get("defensive_symbol"),
)
+ design_fraction = sum(target_weights.values())
overlay = None
if overlay_cfg.any_enabled:
overlay = {
"enabled": True,
- "scale": float((overlay_state or {}).get("scale", 1.0) or 1.0) if overlay_state else None,
+ "scale": scale if overlay_state else None,
"summary": describe_decision(overlay_state),
"reasons": list((overlay_state or {}).get("reasons") or []),
"data_issues": list((overlay_state or {}).get("data_issues") or []),
@@ -221,16 +222,6 @@ def get_baskets_json() -> dict:
}
# 종목별 목표 비중(총자산 대비) = 바스켓 내 비중 정규화 × 적용 투자 비중.
# 현재가는 장부에 저장하지 않으므로 화면은 매입금액 기준 비중과 나란히 보여준다.
- holdings_cfg = basket_config.get("holdings") or {}
- weight_total = sum(float(weight or 0) for weight in holdings_cfg.values())
- target_weights = (
- {
- str(symbol): float(weight or 0) / weight_total * design_fraction
- for symbol, weight in holdings_cfg.items()
- }
- if weight_total > 0
- else {}
- )
holdings_cost = float(sum(position["invested"] for position in positions))
holdings_value = (
snapshot["total_value"] - snapshot["cash"] if snapshot else None
@@ -352,12 +343,16 @@ def _api_error(label: str, exc: Exception, message: str) -> web.Response:
async def _security_headers(request: web.Request, handler):
response = await handler(request)
- response.headers["Cache-Control"] = "no-store"
+ static_asset = request.path.startswith("/static/")
+ # 정적 파일은 변경 여부를 확인해 재사용한다. 계좌 응답은 디스크에 캐시하지 않는다.
+ response.headers["Cache-Control"] = "no-cache" if static_asset else "no-store"
+ if static_asset and request.path.endswith((".css", ".js", ".svg")):
+ response.enable_compression()
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
- "font-src https://fonts.gstatic.com; "
+ "style-src 'self' 'unsafe-inline'; "
+ "font-src 'self'; "
"img-src 'self' data:; connect-src 'self'; "
"object-src 'none'; base-uri 'none'; form-action 'self'; "
"frame-ancestors 'none'"
@@ -537,6 +532,7 @@ def _collect_all() -> dict:
{
"basket": basket_name,
"verdict": result.get("verdict"),
+ "paper_only": bool(result.get("paper_only", False)),
"progress_days": result.get("progress_days"),
"min_trading_days": result.get("min_trading_days"),
"snapshot_coverage": result.get("snapshot_coverage"),
diff --git a/reports/research/dashboard_performance_20260917.json b/reports/research/dashboard_performance_20260917.json
new file mode 100644
index 0000000..baa86d3
--- /dev/null
+++ b/reports/research/dashboard_performance_20260917.json
@@ -0,0 +1,172 @@
+{
+ "environment": {
+ "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
+ "viewport": [
+ 390,
+ 844
+ ],
+ "cpuSlowdown": 4,
+ "downloadMbps": 1.6,
+ "rttMs": 150,
+ "cache": "disabled",
+ "reducedMotion": false
+ },
+ "dataReadyMs": 1640.199999999255,
+ "fcpMs": 628,
+ "lcpMs": 628,
+ "cls": 0.03408044418879106,
+ "longTasks": [
+ {
+ "start": 3059.5999999996275,
+ "duration": 168
+ }
+ ],
+ "blockingMs": 118,
+ "idleRafCalls": 0,
+ "documentBytes": 22915,
+ "resources": [
+ {
+ "path": "/static/fonts/nungum-ui.woff2",
+ "bytes": 441212,
+ "encoded": 440912,
+ "decoded": 440912,
+ "duration": 2837
+ },
+ {
+ "path": "/static/dashboard.css",
+ "bytes": 6632,
+ "encoded": 6332,
+ "decoded": 28389,
+ "duration": 249
+ },
+ {
+ "path": "/static/nungum-symbol.svg",
+ "bytes": 595,
+ "encoded": 295,
+ "decoded": 364,
+ "duration": 202
+ },
+ {
+ "path": "/static/dashboard.js",
+ "bytes": 21213,
+ "encoded": 20913,
+ "decoded": 77165,
+ "duration": 386
+ },
+ {
+ "path": "/api/baskets",
+ "bytes": 3619,
+ "encoded": 3319,
+ "decoded": 3319,
+ "duration": 242
+ },
+ {
+ "path": "/api/portfolio",
+ "bytes": 2607,
+ "encoded": 2307,
+ "decoded": 2307,
+ "duration": 225
+ },
+ {
+ "path": "/api/basket_evaluation",
+ "bytes": 800,
+ "encoded": 500,
+ "decoded": 500,
+ "duration": 178
+ },
+ {
+ "path": "/api/runtime",
+ "bytes": 2639,
+ "encoded": 2339,
+ "decoded": 2339,
+ "duration": 208
+ },
+ {
+ "path": "/api/cash_flows",
+ "bytes": 363,
+ "encoded": 63,
+ "decoded": 63,
+ "duration": 170
+ },
+ {
+ "path": "/api/cash_flows",
+ "bytes": 440,
+ "encoded": 140,
+ "decoded": 140,
+ "duration": 169
+ },
+ {
+ "path": "/api/snapshots",
+ "bytes": 15650,
+ "encoded": 15350,
+ "decoded": 15350,
+ "duration": 372
+ },
+ {
+ "path": "/api/snapshots",
+ "bytes": 10775,
+ "encoded": 10475,
+ "decoded": 10475,
+ "duration": 323
+ },
+ {
+ "path": "/api/snapshots",
+ "bytes": 10773,
+ "encoded": 10473,
+ "decoded": 10473,
+ "duration": 283
+ }
+ ],
+ "interactionEvents": [
+ {
+ "name": "pointerdown",
+ "duration": 56,
+ "interactionId": 8588
+ },
+ {
+ "name": "pointerup",
+ "duration": 56,
+ "interactionId": 8588
+ },
+ {
+ "name": "click",
+ "duration": 56,
+ "interactionId": 8588
+ },
+ {
+ "name": "pointerdown",
+ "duration": 56,
+ "interactionId": 8595
+ },
+ {
+ "name": "pointerup",
+ "duration": 56,
+ "interactionId": 8595
+ },
+ {
+ "name": "click",
+ "duration": 56,
+ "interactionId": 8595
+ },
+ {
+ "name": "pointerdown",
+ "duration": 40,
+ "interactionId": 8602
+ },
+ {
+ "name": "pointerup",
+ "duration": 40,
+ "interactionId": 8602
+ },
+ {
+ "name": "click",
+ "duration": 40,
+ "interactionId": 8602
+ },
+ {
+ "name": "keydown",
+ "duration": 40,
+ "interactionId": 8609
+ }
+ ]
+}
diff --git a/reports/research/risk_review_20260917.json b/reports/research/risk_review_20260917.json
new file mode 100644
index 0000000..e951f28
--- /dev/null
+++ b/reports/research/risk_review_20260917.json
@@ -0,0 +1,844 @@
+{
+ "as_of": "2026-09-17",
+ "last_complete_bar": "2026-09-16",
+ "integer_start": "2020-07-07",
+ "source": "FinanceDataReader, close prices",
+ "data": {
+ "069500": {
+ "first": "2014-06-30",
+ "last": "2026-09-16",
+ "rows": 2999,
+ "sha256": "1ef75c687434ead55cc128f9f933ca589bb4eff2a3390ad3dd104b44822ad933"
+ },
+ "357870": {
+ "first": "2020-07-07",
+ "last": "2026-09-16",
+ "rows": 1520,
+ "sha256": "fbc29674729fc175cf2f1ff78fee361a96a8cfed0279b9fe7cb3b444562cf810"
+ },
+ "KS200": {
+ "first": "2014-01-02",
+ "last": "2026-09-16",
+ "rows": 3119,
+ "sha256": "3834a4d6f22a7278ae4327cd5aa88337681ba0db6e877ff9ede325ebfded70f0"
+ }
+ },
+ "integer_etf": {
+ "static": {
+ "label": "고정 비중",
+ "all": {
+ "years": 6.19,
+ "cagr_pct": 13.54,
+ "period_return_pct": 119.53,
+ "vol_pct": 13.82,
+ "sharpe": 0.8,
+ "mdd_pct": -21.72,
+ "calmar": 0.62,
+ "worst_year_pct": -10.48,
+ "losing_years": 2,
+ "total_years": 7,
+ "negative_months_pct": 41.3,
+ "avg_exposure_pct": 46.8,
+ "turnover_per_year_pct": 39.5,
+ "final_value": 13876896,
+ "contributed": 7700000,
+ "profit": 6176896,
+ "yearly": {
+ "2020": 15.54,
+ "2021": 1.67,
+ "2022": -10.48,
+ "2023": 13.15,
+ "2024": -2.83,
+ "2025": 40.61,
+ "2026": 35.03
+ }
+ },
+ "periods": {
+ "2020_2022": {
+ "years": 2.48,
+ "cagr_pct": 2.05,
+ "period_return_pct": 5.16,
+ "vol_pct": 8.26,
+ "sharpe": -0.07,
+ "mdd_pct": -16.25,
+ "calmar": 0.13,
+ "worst_year_pct": -10.48,
+ "losing_years": 1,
+ "total_years": 3,
+ "negative_months_pct": 40.0,
+ "avg_exposure_pct": 45.7,
+ "turnover_per_year_pct": 67.7,
+ "final_value": 3036402,
+ "contributed": 3200000,
+ "profit": -163598,
+ "yearly": {
+ "2020": 15.54,
+ "2021": 1.67,
+ "2022": -10.48
+ }
+ },
+ "2023_2025": {
+ "years": 2.99,
+ "cagr_pct": 15.67,
+ "period_return_pct": 54.6,
+ "vol_pct": 9.35,
+ "sharpe": 1.33,
+ "mdd_pct": -9.51,
+ "calmar": 1.65,
+ "worst_year_pct": -2.83,
+ "losing_years": 1,
+ "total_years": 3,
+ "negative_months_pct": 41.7,
+ "avg_exposure_pct": 47.3,
+ "turnover_per_year_pct": 28.0,
+ "final_value": 9563195,
+ "contributed": 6800000,
+ "profit": 2763195,
+ "yearly": {
+ "2023": 13.15,
+ "2024": -2.83,
+ "2025": 40.61
+ }
+ },
+ "2026": {
+ "years": 0.7,
+ "cagr_pct": 53.24,
+ "period_return_pct": 35.03,
+ "vol_pct": 32.54,
+ "sharpe": 1.41,
+ "mdd_pct": -21.72,
+ "calmar": 2.45,
+ "worst_year_pct": 35.03,
+ "losing_years": 0,
+ "total_years": 1,
+ "negative_months_pct": 44.4,
+ "avg_exposure_pct": 48.4,
+ "turnover_per_year_pct": 47.1,
+ "final_value": 13876896,
+ "contributed": 7700000,
+ "profit": 6176896,
+ "yearly": {
+ "2026": 35.03
+ }
+ }
+ },
+ "triple_cost": {
+ "years": 6.19,
+ "cagr_pct": 13.42,
+ "period_return_pct": 118.11,
+ "vol_pct": 13.88,
+ "sharpe": 0.78,
+ "mdd_pct": -21.75,
+ "calmar": 0.62,
+ "worst_year_pct": -10.75,
+ "losing_years": 2,
+ "total_years": 7,
+ "negative_months_pct": 42.7,
+ "avg_exposure_pct": 46.9,
+ "turnover_per_year_pct": 39.6,
+ "final_value": 13850257,
+ "contributed": 7700000,
+ "profit": 6150257,
+ "yearly": {
+ "2020": 15.33,
+ "2021": 1.58,
+ "2022": -10.75,
+ "2023": 13.15,
+ "2024": -2.84,
+ "2025": 40.35,
+ "2026": 35.2
+ }
+ }
+ },
+ "old_product": {
+ "label": "기존 방식",
+ "all": {
+ "years": 6.19,
+ "cagr_pct": 10.74,
+ "period_return_pct": 88.12,
+ "vol_pct": 10.85,
+ "sharpe": 0.74,
+ "mdd_pct": -16.02,
+ "calmar": 0.67,
+ "worst_year_pct": -5.67,
+ "losing_years": 2,
+ "total_years": 7,
+ "negative_months_pct": 42.7,
+ "avg_exposure_pct": 29.4,
+ "turnover_per_year_pct": 184.8,
+ "final_value": 12256469,
+ "contributed": 7700000,
+ "profit": 4556469,
+ "yearly": {
+ "2020": 15.54,
+ "2021": 1.75,
+ "2022": -5.67,
+ "2023": 3.63,
+ "2024": -4.68,
+ "2025": 28.73,
+ "2026": 33.4
+ }
+ },
+ "periods": {
+ "2020_2022": {
+ "years": 2.48,
+ "cagr_pct": 4.26,
+ "period_return_pct": 10.9,
+ "vol_pct": 6.15,
+ "sharpe": 0.23,
+ "mdd_pct": -10.05,
+ "calmar": 0.42,
+ "worst_year_pct": -5.67,
+ "losing_years": 1,
+ "total_years": 3,
+ "negative_months_pct": 43.3,
+ "avg_exposure_pct": 31.9,
+ "turnover_per_year_pct": 74.5,
+ "final_value": 3160711,
+ "contributed": 3200000,
+ "profit": -39289,
+ "yearly": {
+ "2020": 15.54,
+ "2021": 1.75,
+ "2022": -5.67
+ }
+ },
+ "2023_2025": {
+ "years": 2.99,
+ "cagr_pct": 8.36,
+ "period_return_pct": 27.16,
+ "vol_pct": 6.4,
+ "sharpe": 0.86,
+ "mdd_pct": -7.86,
+ "calmar": 1.06,
+ "worst_year_pct": -4.68,
+ "losing_years": 1,
+ "total_years": 3,
+ "negative_months_pct": 41.7,
+ "avg_exposure_pct": 24.8,
+ "turnover_per_year_pct": 132.4,
+ "final_value": 8461683,
+ "contributed": 6800000,
+ "profit": 1661683,
+ "yearly": {
+ "2023": 3.63,
+ "2024": -4.68,
+ "2025": 28.73
+ }
+ },
+ "2026": {
+ "years": 0.7,
+ "cagr_pct": 50.62,
+ "period_return_pct": 33.4,
+ "vol_pct": 26.85,
+ "sharpe": 1.58,
+ "mdd_pct": -16.02,
+ "calmar": 3.16,
+ "worst_year_pct": 33.4,
+ "losing_years": 0,
+ "total_years": 1,
+ "negative_months_pct": 44.4,
+ "avg_exposure_pct": 39.6,
+ "turnover_per_year_pct": 351.4,
+ "final_value": 12256469,
+ "contributed": 7700000,
+ "profit": 4556469,
+ "yearly": {
+ "2026": 33.4
+ }
+ }
+ },
+ "triple_cost": {
+ "years": 6.19,
+ "cagr_pct": 10.27,
+ "period_return_pct": 83.24,
+ "vol_pct": 10.81,
+ "sharpe": 0.71,
+ "mdd_pct": -16.11,
+ "calmar": 0.64,
+ "worst_year_pct": -5.91,
+ "losing_years": 2,
+ "total_years": 7,
+ "negative_months_pct": 42.7,
+ "avg_exposure_pct": 28.8,
+ "turnover_per_year_pct": 186.0,
+ "final_value": 12053641,
+ "contributed": 7700000,
+ "profit": 4353641,
+ "yearly": {
+ "2020": 15.33,
+ "2021": 1.63,
+ "2022": -5.91,
+ "2023": 3.26,
+ "2024": -5.4,
+ "2025": 27.59,
+ "2026": 33.31
+ }
+ }
+ },
+ "minimum": {
+ "label": "주식만 조절·중복 축소 방지",
+ "all": {
+ "years": 6.19,
+ "cagr_pct": 12.97,
+ "period_return_pct": 112.81,
+ "vol_pct": 11.26,
+ "sharpe": 0.9,
+ "mdd_pct": -15.74,
+ "calmar": 0.82,
+ "worst_year_pct": -4.12,
+ "losing_years": 2,
+ "total_years": 7,
+ "negative_months_pct": 40.0,
+ "avg_exposure_pct": 36.7,
+ "turnover_per_year_pct": 197.9,
+ "final_value": 13322718,
+ "contributed": 7700000,
+ "profit": 5622718,
+ "yearly": {
+ "2020": 15.54,
+ "2021": 1.9,
+ "2022": -4.12,
+ "2023": 7.47,
+ "2024": -3.96,
+ "2025": 36.66,
+ "2026": 33.66
+ }
+ },
+ "periods": {
+ "2020_2022": {
+ "years": 2.48,
+ "cagr_pct": 5.01,
+ "period_return_pct": 12.88,
+ "vol_pct": 6.26,
+ "sharpe": 0.35,
+ "mdd_pct": -9.21,
+ "calmar": 0.54,
+ "worst_year_pct": -4.12,
+ "losing_years": 1,
+ "total_years": 3,
+ "negative_months_pct": 36.7,
+ "avg_exposure_pct": 33.0,
+ "turnover_per_year_pct": 82.8,
+ "final_value": 3208413,
+ "contributed": 3200000,
+ "profit": 8413,
+ "yearly": {
+ "2020": 15.54,
+ "2021": 1.9,
+ "2022": -4.12
+ }
+ },
+ "2023_2025": {
+ "years": 2.99,
+ "cagr_pct": 12.18,
+ "period_return_pct": 41.05,
+ "vol_pct": 8.09,
+ "sharpe": 1.14,
+ "mdd_pct": -8.8,
+ "calmar": 1.38,
+ "worst_year_pct": -3.96,
+ "losing_years": 1,
+ "total_years": 3,
+ "negative_months_pct": 41.7,
+ "avg_exposure_pct": 39.3,
+ "turnover_per_year_pct": 157.9,
+ "final_value": 9242724,
+ "contributed": 6800000,
+ "profit": 2442724,
+ "yearly": {
+ "2023": 7.47,
+ "2024": -3.96,
+ "2025": 36.66
+ }
+ },
+ "2026": {
+ "years": 0.7,
+ "cagr_pct": 51.03,
+ "period_return_pct": 33.66,
+ "vol_pct": 26.31,
+ "sharpe": 1.62,
+ "mdd_pct": -15.74,
+ "calmar": 3.24,
+ "worst_year_pct": 33.66,
+ "losing_years": 0,
+ "total_years": 1,
+ "negative_months_pct": 44.4,
+ "avg_exposure_pct": 39.0,
+ "turnover_per_year_pct": 335.2,
+ "final_value": 13322718,
+ "contributed": 7700000,
+ "profit": 5622718,
+ "yearly": {
+ "2026": 33.66
+ }
+ }
+ },
+ "triple_cost": {
+ "years": 6.19,
+ "cagr_pct": 12.68,
+ "period_return_pct": 109.44,
+ "vol_pct": 11.28,
+ "sharpe": 0.88,
+ "mdd_pct": -15.73,
+ "calmar": 0.81,
+ "worst_year_pct": -4.18,
+ "losing_years": 2,
+ "total_years": 7,
+ "negative_months_pct": 40.0,
+ "avg_exposure_pct": 36.7,
+ "turnover_per_year_pct": 197.1,
+ "final_value": 13189202,
+ "contributed": 7700000,
+ "profit": 5489202,
+ "yearly": {
+ "2020": 15.33,
+ "2021": 1.75,
+ "2022": -4.18,
+ "2023": 6.99,
+ "2024": -4.06,
+ "2025": 36.66,
+ "2026": 32.79
+ }
+ }
+ }
+ },
+ "fractional_research": {
+ "static": {
+ "all": {
+ "years": 12.21,
+ "cagr_pct": 9.59,
+ "period_return_pct": 206.13,
+ "vol_pct": 12.06,
+ "sharpe": 0.59,
+ "mdd_pct": -21.73,
+ "calmar": 0.44,
+ "worst_year_pct": -11.21,
+ "losing_years": 4,
+ "total_years": 13,
+ "negative_months_pct": 41.9,
+ "avg_exposure_pct": 50.9,
+ "turnover_per_year_pct": 16.7,
+ "final_value": 35409900,
+ "contributed": 15000000,
+ "profit": 20409900,
+ "yearly": {
+ "2014": -1.59,
+ "2015": 1.53,
+ "2016": 6.54,
+ "2017": 14.43,
+ "2018": -7.34,
+ "2019": 8.81,
+ "2020": 21.17,
+ "2021": 3.11,
+ "2022": -11.21,
+ "2023": 13.89,
+ "2024": -3.35,
+ "2025": 44.44,
+ "2026": 41.33
+ }
+ },
+ "zero_cash_yield": {
+ "years": 12.21,
+ "cagr_pct": 8.06,
+ "period_return_pct": 157.67,
+ "vol_pct": 12.08,
+ "sharpe": 0.47,
+ "mdd_pct": -21.87,
+ "calmar": 0.37,
+ "worst_year_pct": -12.5,
+ "losing_years": 4,
+ "total_years": 13,
+ "negative_months_pct": 43.9,
+ "avg_exposure_pct": 50.9,
+ "turnover_per_year_pct": 16.4,
+ "final_value": 32154384,
+ "contributed": 15000000,
+ "profit": 17154384,
+ "yearly": {
+ "2014": -2.33,
+ "2015": 0.06,
+ "2016": 5.02,
+ "2017": 12.81,
+ "2018": -8.67,
+ "2019": 7.27,
+ "2020": 19.59,
+ "2021": 1.62,
+ "2022": -12.5,
+ "2023": 12.3,
+ "2024": -4.7,
+ "2025": 42.47,
+ "2026": 40.1
+ }
+ },
+ "periods": {
+ "2014_2018": {
+ "years": 4.5,
+ "cagr_pct": 2.73,
+ "period_return_pct": 12.88,
+ "vol_pct": 6.06,
+ "sharpe": -0.01,
+ "mdd_pct": -10.33,
+ "calmar": 0.26,
+ "worst_year_pct": -7.34,
+ "losing_years": 2,
+ "total_years": 5,
+ "negative_months_pct": 41.8,
+ "avg_exposure_pct": 49.9,
+ "turnover_per_year_pct": 19.3,
+ "final_value": 6011424,
+ "contributed": 5700000,
+ "profit": 311424,
+ "yearly": {
+ "2014": -1.59,
+ "2015": 1.53,
+ "2016": 6.54,
+ "2017": 14.43,
+ "2018": -7.34
+ }
+ },
+ "2019_2022": {
+ "years": 3.99,
+ "cagr_pct": 4.83,
+ "period_return_pct": 20.7,
+ "vol_pct": 10.26,
+ "sharpe": 0.23,
+ "mdd_pct": -18.04,
+ "calmar": 0.27,
+ "worst_year_pct": -11.21,
+ "losing_years": 1,
+ "total_years": 4,
+ "negative_months_pct": 41.7,
+ "avg_exposure_pct": 50.9,
+ "turnover_per_year_pct": 10.1,
+ "final_value": 12189747,
+ "contributed": 10500000,
+ "profit": 1689747,
+ "yearly": {
+ "2019": 8.81,
+ "2020": 21.17,
+ "2021": 3.11,
+ "2022": -11.21
+ }
+ },
+ "2023_2026": {
+ "years": 3.7,
+ "cagr_pct": 24.43,
+ "period_return_pct": 124.7,
+ "vol_pct": 17.92,
+ "sharpe": 1.18,
+ "mdd_pct": -21.73,
+ "calmar": 1.12,
+ "worst_year_pct": -3.35,
+ "losing_years": 1,
+ "total_years": 4,
+ "negative_months_pct": 42.2,
+ "avg_exposure_pct": 51.9,
+ "turnover_per_year_pct": 19.7,
+ "final_value": 35409900,
+ "contributed": 15000000,
+ "profit": 20409900,
+ "yearly": {
+ "2023": 13.89,
+ "2024": -3.35,
+ "2025": 44.44,
+ "2026": 41.33
+ }
+ }
+ }
+ },
+ "old_product": {
+ "all": {
+ "years": 12.21,
+ "cagr_pct": 8.65,
+ "period_return_pct": 175.32,
+ "vol_pct": 9.27,
+ "sharpe": 0.64,
+ "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
+ }
+ },
+ "zero_cash_yield": {
+ "years": 12.21,
+ "cagr_pct": 5.9,
+ "period_return_pct": 101.43,
+ "vol_pct": 8.68,
+ "sharpe": 0.38,
+ "mdd_pct": -16.44,
+ "calmar": 0.36,
+ "worst_year_pct": -5.92,
+ "losing_years": 5,
+ "total_years": 13,
+ "negative_months_pct": 43.9,
+ "avg_exposure_pct": 33.0,
+ "turnover_per_year_pct": 68.8,
+ "final_value": 26905077,
+ "contributed": 15000000,
+ "profit": 11905077,
+ "yearly": {
+ "2014": -2.69,
+ "2015": -1.36,
+ "2016": 4.35,
+ "2017": 12.83,
+ "2018": -5.47,
+ "2019": 3.26,
+ "2020": 7.87,
+ "2021": 2.13,
+ "2022": -5.92,
+ "2023": 3.04,
+ "2024": -3.09,
+ "2025": 30.3,
+ "2026": 35.4
+ }
+ },
+ "periods": {
+ "2014_2018": {
+ "years": 4.5,
+ "cagr_pct": 3.31,
+ "period_return_pct": 15.78,
+ "vol_pct": 4.59,
+ "sharpe": 0.1,
+ "mdd_pct": -6.38,
+ "calmar": 0.52,
+ "worst_year_pct": -3.49,
+ "losing_years": 2,
+ "total_years": 5,
+ "negative_months_pct": 38.2,
+ "avg_exposure_pct": 38.1,
+ "turnover_per_year_pct": 31.8,
+ "final_value": 6229198,
+ "contributed": 5700000,
+ "profit": 529198,
+ "yearly": {
+ "2014": -1.83,
+ "2015": 0.7,
+ "2016": 6.05,
+ "2017": 14.43,
+ "2018": -3.49
+ }
+ },
+ "2019_2022": {
+ "years": 3.99,
+ "cagr_pct": 4.26,
+ "period_return_pct": 18.12,
+ "vol_pct": 6.98,
+ "sharpe": 0.21,
+ "mdd_pct": -11.84,
+ "calmar": 0.36,
+ "worst_year_pct": -4.27,
+ "losing_years": 1,
+ "total_years": 4,
+ "negative_months_pct": 41.7,
+ "avg_exposure_pct": 34.7,
+ "turnover_per_year_pct": 38.4,
+ "final_value": 12472365,
+ "contributed": 10500000,
+ "profit": 1972365,
+ "yearly": {
+ "2019": 5.33,
+ "2020": 12.72,
+ "2021": 3.92,
+ "2022": -4.27
+ }
+ },
+ "2023_2026": {
+ "years": 3.7,
+ "cagr_pct": 20.79,
+ "period_return_pct": 101.33,
+ "vol_pct": 14.32,
+ "sharpe": 1.22,
+ "mdd_pct": -16.42,
+ "calmar": 1.27,
+ "worst_year_pct": -4.57,
+ "losing_years": 1,
+ "total_years": 4,
+ "negative_months_pct": 40.0,
+ "avg_exposure_pct": 42.2,
+ "turnover_per_year_pct": 100.0,
+ "final_value": 32804251,
+ "contributed": 15000000,
+ "profit": 17804251,
+ "yearly": {
+ "2023": 7.87,
+ "2024": -4.57,
+ "2025": 40.75,
+ "2026": 38.96
+ }
+ }
+ }
+ },
+ "minimum": {
+ "all": {
+ "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.9,
+ "avg_exposure_pct": 38.7,
+ "turnover_per_year_pct": 71.0,
+ "final_value": 33748221,
+ "contributed": 15000000,
+ "profit": 18748221,
+ "yearly": {
+ "2014": -1.83,
+ "2015": 0.7,
+ "2016": 6.05,
+ "2017": 14.43,
+ "2018": -3.49,
+ "2019": 5.33,
+ "2020": 18.43,
+ "2021": 3.91,
+ "2022": -4.28,
+ "2023": 7.85,
+ "2024": -4.58,
+ "2025": 40.77,
+ "2026": 38.96
+ }
+ },
+ "zero_cash_yield": {
+ "years": 12.21,
+ "cagr_pct": 6.39,
+ "period_return_pct": 113.04,
+ "vol_pct": 9.12,
+ "sharpe": 0.41,
+ "mdd_pct": -16.4,
+ "calmar": 0.39,
+ "worst_year_pct": -6.35,
+ "losing_years": 5,
+ "total_years": 13,
+ "negative_months_pct": 43.9,
+ "avg_exposure_pct": 36.5,
+ "turnover_per_year_pct": 68.0,
+ "final_value": 27517557,
+ "contributed": 15000000,
+ "profit": 12517557,
+ "yearly": {
+ "2014": -2.69,
+ "2015": -1.36,
+ "2016": 4.35,
+ "2017": 12.83,
+ "2018": -5.47,
+ "2019": 3.26,
+ "2020": 15.22,
+ "2021": 2.67,
+ "2022": -6.35,
+ "2023": 6.07,
+ "2024": -6.21,
+ "2025": 33.89,
+ "2026": 30.85
+ }
+ },
+ "periods": {
+ "2014_2018": {
+ "years": 4.5,
+ "cagr_pct": 3.31,
+ "period_return_pct": 15.78,
+ "vol_pct": 4.59,
+ "sharpe": 0.1,
+ "mdd_pct": -6.38,
+ "calmar": 0.52,
+ "worst_year_pct": -3.49,
+ "losing_years": 2,
+ "total_years": 5,
+ "negative_months_pct": 38.2,
+ "avg_exposure_pct": 38.1,
+ "turnover_per_year_pct": 31.8,
+ "final_value": 6229198,
+ "contributed": 5700000,
+ "profit": 529198,
+ "yearly": {
+ "2014": -1.83,
+ "2015": 0.7,
+ "2016": 6.05,
+ "2017": 14.43,
+ "2018": -3.49
+ }
+ },
+ "2019_2022": {
+ "years": 3.99,
+ "cagr_pct": 5.56,
+ "period_return_pct": 24.07,
+ "vol_pct": 7.55,
+ "sharpe": 0.37,
+ "mdd_pct": -11.83,
+ "calmar": 0.47,
+ "worst_year_pct": -4.28,
+ "losing_years": 1,
+ "total_years": 4,
+ "negative_months_pct": 41.7,
+ "avg_exposure_pct": 36.3,
+ "turnover_per_year_pct": 34.3,
+ "final_value": 12942422,
+ "contributed": 10500000,
+ "profit": 2442422,
+ "yearly": {
+ "2019": 5.33,
+ "2020": 18.43,
+ "2021": 3.91,
+ "2022": -4.28
+ }
+ },
+ "2023_2026": {
+ "years": 3.7,
+ "cagr_pct": 20.79,
+ "period_return_pct": 101.3,
+ "vol_pct": 14.32,
+ "sharpe": 1.22,
+ "mdd_pct": -16.42,
+ "calmar": 1.27,
+ "worst_year_pct": -4.58,
+ "losing_years": 1,
+ "total_years": 4,
+ "negative_months_pct": 40.0,
+ "avg_exposure_pct": 42.1,
+ "turnover_per_year_pct": 100.0,
+ "final_value": 33748221,
+ "contributed": 15000000,
+ "profit": 18748221,
+ "yearly": {
+ "2023": 7.85,
+ "2024": -4.58,
+ "2025": 40.77,
+ "2026": 38.96
+ }
+ }
+ }
+ }
+ },
+ "limitations": [
+ "동일 기간을 이미 살펴본 사후 검증이며 독립적인 미사용 표본이 아님",
+ "ETF 분배금 미포함; 현금 이자 0%, CD ETF 양의 매매차익 15.4% 상한 과세",
+ "다음 거래일 종가에 비용을 더한 근사 체결; 실시간 호가·괴리율·미체결 미재현",
+ "소수 주 연구의 현금금리는 연 3% 고정 가정; 금리 0% 민감도도 공개",
+ "부분 연도는 연도 전체 수익률이 아님; 실전 자동 전환 없음"
+ ]
+}
\ No newline at end of file
diff --git a/tests/test_basket_evaluation.py b/tests/test_basket_evaluation.py
index bad0ff8..fe432d0 100644
--- a/tests/test_basket_evaluation.py
+++ b/tests/test_basket_evaluation.py
@@ -201,6 +201,19 @@ def test_collector_uses_basket_initial_capital(self):
)
assert result["metrics"]["initial_capital"] == 30_000_000
+ def test_changed_policy_reports_wait_even_if_old_operation_passed(self):
+ from unittest.mock import patch
+ from core.basket_evaluation import collect_basket_paper_evaluation
+ baskets = {"changed": {"enabled": True, "holdings": {"069500": 1.},
+ "promotion": {"paper_only": True, "review_note": "새 규칙 검증 중"}}}
+ with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config", return_value=baskets), \
+ patch("core.basket_evaluation.evaluate_basket_paper_operation", return_value={"verdict": "PASS_CANDIDATE", "issues": []}):
+ result, _ = collect_basket_paper_evaluation(basket_name="changed", include_benchmark=False)
+ assert result["verdict"] == "WAIT"
+ assert result["operation_verdict"] == "PASS_CANDIDATE"
+ assert result["paper_only"] is True
+ assert "새 규칙 검증 중" in result["issues"]
+
def test_collector_falls_back_to_global_capital(self):
from unittest.mock import patch
from core.basket_rebalancer import BasketRebalancer
diff --git a/tests/test_basket_live_gate.py b/tests/test_basket_live_gate.py
index a20b230..28edf5d 100644
--- a/tests/test_basket_live_gate.py
+++ b/tests/test_basket_live_gate.py
@@ -64,6 +64,14 @@ def test_pass_candidate_opens_gate(self):
issues = self._run("basket_rebalance:kr_diversified_hold")
assert issues == []
+ def test_changed_policy_cannot_reuse_old_pass_or_mock_bypass(self):
+ baskets = {"changed": {"enabled": True, "holdings": {"069500": 1.},
+ "promotion": {"paper_only": True}}}
+ for use_mock in (False, True):
+ issues = self._run("basket_rebalance:changed", baskets=baskets,
+ verdict="PASS_CANDIDATE", use_mock=use_mock)
+ assert any("paper_only" in issue for issue in issues)
+
def test_gate_evaluates_its_own_basket_record(self):
"""게이트는 반드시 '자기 바스켓'의 기록으로 평가한다 — 이름 없이 합산하면
다른 바스켓의 60일 트랙레코드로 신규 바스켓이 승격되는 구멍."""
diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py
index 0ae14c1..759dca3 100644
--- a/tests/test_basket_overlay_integration.py
+++ b/tests/test_basket_overlay_integration.py
@@ -111,6 +111,14 @@ def test_missing_index_data_keeps_previous_state_and_flags(self):
assert decision.scale == pytest.approx(0.5)
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))
+ rb = _make(_basket(self.overlays), [100.] * 199 + [110., float("nan")])
+ with _patch_snapshots(rb):
+ decision = rb.overlay_decision()
+ assert decision.scale == .5
+ assert decision.data_issues
+
def test_today_bar_is_excluded(self):
"""오늘 날짜 봉은 전일까지의 정보가 아니므로 제외한다."""
from datetime import datetime
@@ -143,3 +151,37 @@ def test_recovery_releases(self):
rb = _make(_basket(self.overlays), closes=None, cumulative=[0.0, 10.0, 5.0, 6.0])
with _patch_snapshots(rb):
assert rb._stock_fraction() == pytest.approx(0.6) # 낙폭 -3.6% → -5% 안 → 해제
+
+
+def test_defensive_etf_is_retained_and_gets_released_equity_allocation():
+ cfg = _basket({"combination": "minimum", "defensive_symbol": "357870",
+ "trend_filter": {"enabled": True}, "drawdown_guard": {"enabled": True}}, target=.95)
+ cfg["holdings"] = {"069500": .5, "357870": .5}
+ rb = _make(cfg, closes=[100.]*199+[90.], cumulative=[0., -12.])
+ with _patch_snapshots(rb):
+ assert rb.overlay_decision().scale == .5
+ assert rb._stock_fraction() == pytest.approx(.95)
+ targets = rb.get_target_weights()
+ assert targets == pytest.approx({"069500": .25, "357870": .75})
+ assert targets["357870"] * rb._stock_fraction() == pytest.approx(.7125)
+
+
+def test_future_index_bar_and_unsorted_rows_cannot_change_signal():
+ cfg = _basket({"trend_filter": {"enabled": True, "ma_days": 200}})
+ rb = _make(cfg, closes=[])
+ from datetime import datetime, timedelta
+ future = datetime.now() + timedelta(days=2)
+ dates = list(pd.date_range(end="2020-01-01", periods=200, freq="B")) + [future]
+ frame = pd.DataFrame({"date":dates,"close":[100.]*200 + [10000.]})
+ rb.data_collector.fetch_korean_stock = lambda *a: frame.iloc[::-1]
+ assert rb._fetch_index_closes("KS200", 200) == [100.]*200
+
+
+def test_nav_ignores_today_and_future_snapshots():
+ cfg = _basket({"drawdown_guard": {"enabled": True}})
+ rb = _make(cfg, closes=[], cumulative=[0., -12., 100.])
+ from datetime import datetime, timedelta
+ today = datetime.now().date()
+ rb._nav_frame["date"] = [today-timedelta(days=2), today-timedelta(days=1), today]
+ with _patch_snapshots(rb):
+ assert rb.overlay_decision().drawdown_active is True
diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py
index 0c4ed41..bbb0a50 100644
--- a/tests/test_critical_fixes.py
+++ b/tests/test_critical_fixes.py
@@ -945,7 +945,9 @@ def test_dashboard_docs_and_config_default_to_loopback(self):
settings = settings_path.read_text(encoding="utf-8")
assert 'host: "127.0.0.1"' in settings
assert 'host: "127.0.0.1"' in example
- assert "기본 바인드는 http://127.0.0.1:8080" in readme
+ # 문장과 Markdown 표현은 바뀌어도 로컬 접속 주소 계약은 유지한다.
+ assert "http://127.0.0.1:8080" in readme
+ assert "http://0.0.0.0:8080" not in readme
assert "| **dashboard** | host(127.0.0.1), port(8080) |" in project_guide
assert "| **dashboard** | host(0.0.0.0), port(8080) |" not in project_guide
diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py
index ed856f6..2a12509 100644
--- a/tests/test_dashboard_baskets.py
+++ b/tests/test_dashboard_baskets.py
@@ -69,6 +69,23 @@ def _cfg(basket_name):
class TestGetBasketsJson:
+ def test_defensive_target_and_zero_scale_match_order_planner(self, tmp_path, monkeypatch):
+ from core.risk_overlays import OverlayDecision, save_overlay_state
+ monkeypatch.setenv("QUANT_OVERLAY_STATE_DIR", str(tmp_path))
+ name = "dashboard_defensive_zero"
+ _seed_pocket(name)
+ cfg = _cfg(name)
+ cfg[name].update({"target_stock_weight": .95, "min_cash_ratio": .05,
+ "holdings": {"069500":.5,"357870":.5},
+ "overlays":{"trend_filter":{"enabled":True},"defensive_symbol":"357870"}})
+ save_overlay_state(name, OverlayDecision(scale=0.))
+ from monitoring import web_dashboard as wd
+ with patch("core.basket_rebalancer.BasketRebalancer.get_enabled_baskets",return_value=[name]), patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config",return_value=cfg):
+ b = wd.get_baskets_json()["baskets"][0]
+ assert b["overlay"]["scale"] == 0.
+ assert b["design_fraction"] == pytest.approx(.95)
+ assert b["target_weights"] == pytest.approx({"069500":0.,"357870":.95})
+
def test_principal_snapshot_deployment_positions(self):
name = "kr_pocket_t1"
acct = _seed_pocket(name)
@@ -335,8 +352,9 @@ def test_html_page_contains_basket_tracks_section():
assert "basketTracks" in html # 주력 포트폴리오 섹션
assert "chartAccount" in html # 장기 차트 계정 선택기
assert 'id="decisionTitle"' in html # 오늘의 단일 판단
- assert '
300_000
+ np.testing.assert_allclose(frame.twr, 1.0)
+
+
+def test_fees_count_from_first_trade_and_no_cash_borrowing():
+ frame, _ = simulate(
+ prices([100.0] * 4),
+ Policy("all", "전액"),
+ target_stock=1,
+ monthly=0,
+ rf_annual=0,
+ commission=0.01,
+ slippage=0.02,
+ )
+ assert (frame.cash >= -1e-8).all()
+ assert frame.iloc[0].daily_return < -0.02
+ assert frame.iloc[-1].twr < 1
+
+
+def test_full_exit_never_creates_short_position():
+ series = prices([100.0] * 21 + [50.0] * 5)
+ frame, _ = simulate(
+ series,
+ Policy("exit", "청산", trend=True, trend_ma_days=20, trend_off_scale=0),
+ target_stock=1,
+ monthly=0,
+ rf_annual=0,
+ slippage=0.03,
+ )
+ assert (frame.shares >= 0).all()
+ assert frame.iloc[-1].shares == 0
+
+
+def test_changing_future_prices_cannot_change_past_orders():
+ series = prices([100.0] * 25 + [80.0] * 10 + [105.0] * 10)
+ changed = series.copy()
+ changed.iloc[35:] *= 3
+ policy = Policy(
+ "both",
+ "추세와 낙폭",
+ trend=True,
+ trend_ma_days=20,
+ dd=True,
+ combination="minimum",
+ )
+ a, _ = simulate(series, policy)
+ b, _ = simulate(changed, policy)
+ pd.testing.assert_frame_equal(a.iloc[:35], b.iloc[:35])
+
+
+@pytest.mark.parametrize(
+ "values", [[100.0, float("nan")], [100.0, float("inf")], [100.0, 0.0]]
+)
+def test_invalid_prices_are_rejected(values):
+ with pytest.raises(ValueError):
+ simulate(prices(values), Policy("test", "검증"))
+
+
+def test_integer_etf_execution_respects_shares_cash_and_future_boundary():
+ from tools.risk_review import integer_etf_simulation
+
+ idx = pd.bdate_range("2020-01-01", periods=280)
+ panel = pd.DataFrame(
+ {
+ "KS200": [100.0] * 220 + [65.0] * 60,
+ "069500": [12000.0] * 220 + [8000.0] * 60,
+ "357870": [5000.0] * 280,
+ },
+ index=idx,
+ )
+ policy = Policy("min", "방어", trend=True, dd=True, combination="minimum")
+ f, _ = integer_etf_simulation(panel, policy)
+ changed = panel.copy()
+ changed.iloc[245:] = changed.iloc[245:] * 3
+ g, _ = integer_etf_simulation(changed, policy)
+ assert (f.cash >= 0).all()
+ assert (f.qty_069500 >= 0).all() and (f.qty_357870 >= 0).all()
+ assert f.scale.min() == 0.5
+ pd.testing.assert_frame_equal(f.iloc[:45], g.iloc[:45])
+ np.testing.assert_allclose(f.twr, (1 + f.daily_return).cumprod())
diff --git a/tests/test_risk_overlays.py b/tests/test_risk_overlays.py
index 7ecf014..655c3a4 100644
--- a/tests/test_risk_overlays.py
+++ b/tests/test_risk_overlays.py
@@ -84,6 +84,8 @@ def test_drawdown_from_cumulative_returns_uses_initial_capital_as_first_peak(sel
assert drawdown_from_cumulative_returns([10.0, 21.0, 9.9]) == pytest.approx(1.099 / 1.21 - 1)
assert drawdown_from_cumulative_returns([]) is None
assert drawdown_from_cumulative_returns([None, "x"]) is None
+ assert drawdown_from_cumulative_returns([0., None, 10.]) is None
+ assert drawdown_from_cumulative_returns([0., "오류", 10.]) is None
def test_trigger_and_release(self):
assert drawdown_guard_active(-0.09, self.cfg, False) is False
@@ -135,9 +137,43 @@ def test_missing_data_keeps_previous_state_and_flags_issue(self):
def test_describe_is_korean_one_liner(self):
cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}})
d = compute_decision(cfg, index_closes=_closes(103.0), prev_state=None)
- assert describe_decision(d).startswith("위험 조절 발동 없음")
+ assert describe_decision(d).startswith("기본 투자 비중 유지")
assert "첫 실행 대기" in describe_decision(None)
+ def test_minimum_combination_does_not_cut_same_risk_twice(self):
+ cfg = parse_overlay_config({"overlays": {"combination": "minimum", "trend_filter": {"enabled": True}, "drawdown_guard": {"enabled": True}}})
+ d = compute_decision(cfg, index_closes=_closes(95.), cumulative_returns_pct=[0., -12.])
+ assert d.scale == .5
+
+ def test_missing_data_cannot_increase_previous_exposure(self):
+ cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}})
+ d = compute_decision(cfg, index_closes=[], prev_state={"scale": .25})
+ assert d.scale == .25
+ assert d.data_issues
+
+ def test_nonfinite_price_is_not_a_recovery_signal(self):
+ cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}})
+ d = compute_decision(cfg, index_closes=_closes(float('inf')), prev_state={"trend_below": True, "scale": .5})
+ assert d.scale == .5
+ assert d.data_issues
+
+
+class TestDefensiveAllocation:
+ def test_released_stock_weight_goes_to_defensive_asset(self):
+ from core.risk_overlays import overlay_target_weights
+ result = overlay_target_weights({"069500": .5, "357870": .5}, .95, .5, "357870")
+ assert result == pytest.approx({"069500": .2375, "357870": .7125})
+ assert sum(result.values()) == pytest.approx(.95)
+
+ def test_stock_only_basket_keeps_cash_fallback(self):
+ from core.risk_overlays import overlay_target_weights
+ assert overlay_target_weights({"A": 1}, .6, .5) == {"A": .3}
+
+ def test_unknown_defensive_asset_is_not_silently_accepted(self):
+ from core.risk_overlays import overlay_target_weights
+ with pytest.raises(ValueError):
+ overlay_target_weights({"A": 1}, .6, .5, "missing")
+
class TestState:
def test_roundtrip_and_applied_fraction(self, tmp_path):
diff --git a/tools/risk_overlay_backtest.py b/tools/risk_overlay_backtest.py
index 6ffeaaa..91fc760 100644
--- a/tools/risk_overlay_backtest.py
+++ b/tools/risk_overlay_backtest.py
@@ -1,30 +1,17 @@
#!/usr/bin/env python3
-"""리스크 오버레이 정직 백테스트 — 추세 필터·변동성 목표·낙폭 제어가 손실을 줄이는가.
-
-이 저장소의 확정 결론(docs/PROFITABILITY_FINDINGS.md)은 "종목 선택 알파는 없고, 베타를 싸게
-담는 것이 답"이다. 그래서 여기서는 종목을 고르지 않는다. 대신 이미 운용 중인 두 트랙의
-**주식 비중을 언제 줄이고 언제 되돌리는가**만 바꿔 보고, 같은 비용·같은 적립 규칙 아래에서
-낙폭(MDD)·손실 연도·샤프가 어떻게 달라지는지 잰다.
-
-트랙 1 (kr_pocket 형) : 지수 ETF + 금리 파킹, 월 10만원 적립, 목표 주식 비중 50%
-트랙 2 (kr_diversified_hold 형) : 대형주 10종목 동일비중, 목표 주식 비중 60%
-
-정책
- static : 목표 비중 고정 (현재 운용과 동일)
- trend : 지수가 200일선 아래면 주식 비중을 off_scale 배로 축소 (Faber 10개월 SMA 계열)
- vol : 실현 변동성이 목표(연 15%)를 넘으면 목표/실현 비율만큼 축소 (하한 0.5, 상한 1.0)
- trend+vol : 두 배수의 곱
- dd : 시간가중 NAV가 고점 대비 -10% 아래면 0.5배, -5% 안으로 회복하면 복귀
-
-비용: ETF 편도 수수료 0.015% + 슬리피지 0.05%, 증권거래세 없음(국내 주식형 ETF 비과세).
- 개별 주식은 매도 시 0.20% 세금 추가. 파킹 사채·현금은 연 3% 일할 가정.
-데이터: FinanceDataReader. KS200 지수는 2002년부터(배당 제외 가격지수 — 보수적),
- 069500은 2014년부터. 결과는 reports/research/ 와 docs/images/ 에 남긴다.
-
-Usage:
- python tools/risk_overlay_backtest.py # 전체
- python tools/risk_overlay_backtest.py --track pocket # 트랙 1만
+"""추세·낙폭·변동성 규칙의 과거 성과를 비교하는 소수 주 연구용 백테스트.
+
+위험자산 한 종목(또는 고정 수량 주식 바스켓 지수)과 연 3% 가정 현금을 비교한다.
+매월 적립금은 시간가중수익률로 분리하며, 전일 정보로 다음 거래일 종가에 거래한다.
+국내 주식형 ETF는 증권거래세를 제외하고, 개별 주식은 매도 시 0.20%를 적용한다.
+수수료·슬리피지는 반영하지만 ETF 분배금과 실제 호가·정수 주 제약은 반영하지 않는다.
+
+실제 ETF 두 종목과 1주 단위를 쓰는 최신 검증은 tools/risk_review.py에 있다.
+이 실험만으로 초과수익의 가능성이나 향후 성과를 단정하지 않는다.
+
+실행: python tools/risk_overlay_backtest.py --as-of 2026-09-17
"""
+
from __future__ import annotations
import argparse
@@ -37,8 +24,18 @@
_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_ROOT))
-BASKET_SYMBOLS = ["005930", "000660", "035420", "005380", "051910",
- "005490", "055550", "035720", "012330", "105560"]
+BASKET_SYMBOLS = [
+ "005930",
+ "000660",
+ "035420",
+ "005380",
+ "051910",
+ "005490",
+ "055550",
+ "035720",
+ "012330",
+ "105560",
+]
COMMISSION = 0.00015
SLIPPAGE = 0.0005
STOCK_TAX = 0.0020
@@ -53,43 +50,92 @@ class Policy:
trend: bool = False
trend_ma_days: int = 200
trend_off_scale: float = 0.5
- trend_band: float = 0.02 # 200일선 ±2% 히스테리시스 — 선 근처 왕복 매매를 막는다
+ trend_band: float = 0.02 # 200일선 ±2% 히스테리시스 — 선 근처 왕복 매매를 막는다
vol: bool = False
vol_target: float = 0.15
vol_lookback: int = 60
vol_min_scale: float = 0.5
vol_max_scale: float = 1.0
- vol_step: float = 0.1 # 배수를 0.1 단위로 양자화 — 매일 미세 조정하지 않는다
+ vol_step: float = 0.1 # 배수를 0.1 단위로 양자화 — 매일 미세 조정하지 않는다
dd: bool = False
dd_trigger: float = -0.10
dd_release: float = -0.05
dd_scale: float = 0.5
+ combination: str = "product"
notes: list[str] = field(default_factory=list)
POLICIES = [
- Policy("static", "고정 비중(현행)"),
- Policy("trend50", "추세 필터 · 200일선 아래면 절반", trend=True, trend_off_scale=0.5),
+ Policy("static", "고정 비중"),
+ Policy(
+ "trend50", "추세 필터 · 200일선 아래면 절반", trend=True, trend_off_scale=0.5
+ ),
Policy("trend0", "추세 필터 · 200일선 아래면 0", trend=True, trend_off_scale=0.0),
Policy("vol15", "변동성 목표 15%", vol=True),
Policy("vol20", "변동성 목표 20%", vol=True, vol_target=0.20),
Policy("dd10", "낙폭 제어 · -10%에서 절반", dd=True),
- Policy("trend50_dd10", "추세 절반 + 낙폭 제어", trend=True, trend_off_scale=0.5, dd=True),
- Policy("trend50_vol20", "추세 절반 + 변동성 20%", trend=True, trend_off_scale=0.5, vol=True, vol_target=0.20),
+ Policy(
+ "trend50_dd10",
+ "추세 절반 + 낙폭 제어",
+ trend=True,
+ trend_off_scale=0.5,
+ dd=True,
+ ),
+ Policy(
+ "trend50_dd10_min",
+ "추세·낙폭 중 더 낮은 비중",
+ trend=True,
+ dd=True,
+ combination="minimum",
+ ),
+ Policy(
+ "trend50_vol20",
+ "추세 절반 + 변동성 20%",
+ trend=True,
+ trend_off_scale=0.5,
+ vol=True,
+ vol_target=0.20,
+ ),
]
+AS_OF = "2026-09-17"
+CACHE_DIR = _ROOT / "data" / "research_prices"
+
+
def _fdr(symbol: str, start: str):
import FinanceDataReader as fdr
- df = fdr.DataReader(symbol, start)
+ import pandas as pd
+
+ # 당일 미확정 종가와 기준일 이후의 데이터가 재실행 결과에 섞이지 않게 한다.
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ cache = CACHE_DIR / f"{symbol}_{start}_{AS_OF}.csv"
+ if cache.exists():
+ df = pd.read_csv(cache, index_col=0, parse_dates=True)
+ else:
+ df = fdr.DataReader(symbol, start, AS_OF)
+ df.to_csv(cache)
df = df.rename(columns={c: c.lower() for c in df.columns})
- df.index = df.index.tz_localize(None) if getattr(df.index, "tz", None) is not None else df.index
- return df["close"].astype(float).dropna()
+ df.index = (
+ df.index.tz_localize(None)
+ if getattr(df.index, "tz", None) is not None
+ else df.index
+ )
+ result = (
+ df.loc[
+ (df.index >= pd.Timestamp(start)) & (df.index < pd.Timestamp(AS_OF)),
+ "close",
+ ]
+ .astype(float)
+ .dropna()
+ )
+ if result.empty or not result.index.is_unique or not (result > 0).all():
+ raise ValueError(f"{symbol}: 유효한 종가가 없거나 날짜가 중복됐습니다")
+ return result.sort_index()
def _scale_series(closes, nav_twr, policy: Policy, index_closes=None):
"""날짜별 주식 비중 배수(0~1). 모든 계산은 전일까지의 정보만 쓴다(shift 1)."""
- import numpy as np
import pandas as pd
scale = pd.Series(1.0, index=closes.index)
@@ -99,12 +145,14 @@ def _scale_series(closes, nav_twr, policy: Policy, index_closes=None):
rel = (ref / ma - 1).reindex(closes.index).ffill().shift(1)
# 히스테리시스: 선 아래로 band만큼 내려가야 '하락', 위로 band만큼 올라와야 '상승'.
below = []
- state = False
+ state = None
for v in rel.to_numpy():
- if v != v: # NaN
+ if math.isnan(v):
below.append(False)
continue
- if state and v > policy.trend_band:
+ if state is None:
+ state = v < 0.0
+ elif state and v > policy.trend_band:
state = False
elif (not state) and v < -policy.trend_band:
state = True
@@ -115,16 +163,30 @@ def _scale_series(closes, nav_twr, policy: Policy, index_closes=None):
rets = closes.pct_change()
realized = rets.rolling(policy.vol_lookback).std() * math.sqrt(TRADING_DAYS)
ratio = (policy.vol_target / realized).shift(1)
- vol_scale = ratio.clip(lower=policy.vol_min_scale, upper=policy.vol_max_scale).fillna(1.0)
+ vol_scale = ratio.clip(
+ lower=policy.vol_min_scale, upper=policy.vol_max_scale
+ ).fillna(1.0)
if policy.vol_step > 0:
vol_scale = (vol_scale / policy.vol_step).round() * policy.vol_step
scale = scale * vol_scale
return scale.clip(0.0, 1.0)
-def simulate(closes, policy: Policy, *, index_closes=None, target_stock=0.5,
- initial=300_000.0, monthly=100_000.0, tax_rate=0.0,
- drift_band=0.08, rf_annual=RF_ANNUAL):
+def simulate(
+ closes,
+ policy: Policy,
+ *,
+ index_closes=None,
+ target_stock=0.5,
+ initial=300_000.0,
+ monthly=100_000.0,
+ tax_rate=0.0,
+ drift_band=0.08,
+ rf_annual=RF_ANNUAL,
+ commission=COMMISSION,
+ slippage=SLIPPAGE,
+ min_trade=1_000.0,
+):
"""단일 위험자산 + 현금성 자산의 DCA 포트폴리오. 위험자산은 소수 주 허용(연구용).
- 매월 첫 거래일에 monthly 적립 → 현금으로 들어와 다음 리밸런싱에서 배분.
@@ -136,6 +198,24 @@ def simulate(closes, policy: Policy, *, index_closes=None, target_stock=0.5,
import numpy as np
import pandas as pd
+ if (
+ closes.empty
+ or not closes.index.is_unique
+ or not closes.index.is_monotonic_increasing
+ ):
+ raise ValueError("종가는 날짜순으로 정렬된, 중복 없는 시계열이어야 합니다")
+ if not np.isfinite(closes.to_numpy()).all() or not (closes > 0).all():
+ raise ValueError("종가는 유한한 양수여야 합니다")
+ if initial <= 0 or monthly < 0 or not 0 <= target_stock <= 1 or rf_annual <= -1:
+ raise ValueError("자본·적립금·목표 비중·금리 설정을 확인하세요")
+ if (
+ commission < 0
+ or not 0 <= slippage < 1
+ or tax_rate < 0
+ or commission + tax_rate >= 1
+ ):
+ raise ValueError("거래비용 설정을 확인하세요")
+
rf_daily = (1 + rf_annual) ** (1 / TRADING_DAYS) - 1
dates = closes.index
scale = _scale_series(closes, None, policy, index_closes)
@@ -151,73 +231,104 @@ def simulate(closes, policy: Policy, *, index_closes=None, target_stock=0.5,
turnover_value = 0.0
exposure_sum = 0.0
rows = []
- prev_total_after_flow = None
+ prev_total = initial
for i, day in enumerate(dates):
price = float(closes.iloc[i])
# 1) 적립 (월 첫 거래일)
flow = 0.0
- if prev_month is not None and day.month != prev_month and monthly > 0:
+ month = (day.year, day.month)
+ if prev_month is not None and month != prev_month and monthly > 0:
cash += monthly
contributed += monthly
flow = monthly
- prev_month = day.month
+ prev_month = month
# 2) 현금 이자
- cash *= (1 + rf_daily)
+ cash *= 1 + rf_daily
total_before = shares * price + cash
# 3) 낙폭 제어 상태 (전일까지의 TWR 기준)
s = float(scale.iloc[i])
+ dd_now = twr_index / peak_index - 1
if policy.dd:
- dd_now = twr_index / peak_index - 1
if dd_active and dd_now >= policy.dd_release:
dd_active = False
elif not dd_active and dd_now <= policy.dd_trigger:
dd_active = True
if dd_active:
- s *= policy.dd_scale
+ s = (
+ min(s, policy.dd_scale)
+ if policy.combination == "minimum"
+ else s * policy.dd_scale
+ )
target_w = target_stock * s
cur_w = shares * price / total_before if total_before > 0 else 0.0
- need = (prev_scale is None) or (abs(cur_w - target_w) > drift_band) or (abs(s - (prev_scale or 0)) > 1e-9 and abs(cur_w - target_w) > 0.005)
+ need = (
+ (prev_scale is None)
+ or (abs(cur_w - target_w) > drift_band)
+ or (abs(s - (prev_scale or 0)) > 1e-9 and abs(cur_w - target_w) > 0.005)
+ )
if flow > 0 and cur_w < target_w - 0.005:
need = True
+ traded = 0.0
+ cost = 0.0
if need:
target_value = total_before * target_w
diff_value = target_value - shares * price
- if abs(diff_value) > 1_000:
- fill = price * (1 + SLIPPAGE) if diff_value > 0 else price * (1 - SLIPPAGE)
- qty = diff_value / fill
- cost = abs(diff_value) * COMMISSION + (abs(diff_value) * tax_rate if diff_value < 0 else 0.0)
+ if abs(diff_value) > min_trade:
+ fill = (
+ price * (1 + slippage) if diff_value > 0 else price * (1 - slippage)
+ )
+ # 수수료까지 포함해 살 수 있는 수량, 실제 보유한 수량 안에서만 체결한다.
+ # 기존 diff/fill 매도는 슬리피지 때문에 전량 매도 때 음수 보유를 만들었다.
+ qty = (
+ min(diff_value / price, cash / (fill * (1 + commission)))
+ if diff_value > 0
+ else -min(shares, -diff_value / price)
+ )
+ traded = abs(qty * fill)
+ cost = traded * (commission + (tax_rate if qty < 0 else 0.0))
cash -= qty * fill + cost
shares += qty
- turnover_value += abs(diff_value)
+ turnover_value += traded
prev_scale = s
total = shares * price + cash
# 4) TWR: 적립 직후 가치 대비 당일 종가 가치
- if prev_total_after_flow is not None and prev_total_after_flow > 0:
- twr_index *= (total / (prev_total_after_flow))
+ daily_return = total / (prev_total + flow) - 1.0
+ twr_index *= 1.0 + daily_return
peak_index = max(peak_index, twr_index)
- prev_total_after_flow = total
- # 다음 날 적립이 들어오면 분모를 적립 포함으로 갱신해야 하므로 flow는 다음 루프의 total_before에 반영됨
+ prev_total = total
exposure_sum += (shares * price / total) if total > 0 else 0.0
- rows.append({"date": day, "total": total, "twr": twr_index, "contributed": contributed,
- "stock_w": shares * price / total if total > 0 else 0.0, "scale": s})
- # 적립이 들어오는 날 TWR 분모 보정: 다음 루프에서 total_before에 flow가 포함되므로
- # prev_total_after_flow에 flow를 더해 둔다 (적립은 수익이 아니다).
+ rows.append(
+ {
+ "date": day,
+ "total": total,
+ "twr": twr_index,
+ "contributed": contributed,
+ "stock_w": shares * price / total if total > 0 else 0.0,
+ "scale": s,
+ "daily_return": daily_return,
+ "flow": flow,
+ "cash": cash,
+ "shares": shares,
+ "drawdown": twr_index / peak_index - 1.0,
+ "signal_drawdown": dd_now,
+ "drawdown_active": dd_active,
+ "trade_value": traded,
+ "cost": cost,
+ }
+ )
frame = pd.DataFrame(rows).set_index("date")
- # TWR 재계산(적립을 분모에 더하는 방식으로 정확히): 일별 수익률 = total_t / (total_{t-1} + flow_t)
- flows = frame["contributed"].diff().fillna(0.0)
- daily = frame["total"] / (frame["total"].shift(1) + flows) - 1
- daily.iloc[0] = 0.0
- twr = (1 + daily).cumprod()
- frame["twr"] = twr
- frame["drawdown"] = twr / twr.cummax() - 1
- return frame, {"turnover_value": turnover_value, "avg_exposure": exposure_sum / len(frame)}
+ # 판단에 쓴 TWR과 보고하는 TWR은 동일하다. 시작일 매수 비용도 지우지 않는다.
+ return frame, {
+ "turnover_value": turnover_value,
+ "avg_exposure": exposure_sum / len(frame),
+ }
def metrics(frame, extra, years_hint=None):
- import numpy as np
+
twr = frame["twr"]
- daily = twr.pct_change().dropna()
+ 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)
@@ -225,23 +336,28 @@ def metrics(frame, extra, years_hint=None):
mdd = frame["drawdown"].min()
calmar = cagr / abs(mdd) if mdd < 0 else float("nan")
yearly = twr.resample("YE").last().pct_change()
- first_year = twr.resample("YE").last().iloc[0] / twr.iloc[0] - 1
+ first_year = twr.resample("YE").last().iloc[0] - 1
yearly.iloc[0] = first_year
- monthly = twr.resample("ME").last().pct_change().dropna()
+ monthly_end = twr.resample("ME").last()
+ monthly = monthly_end.pct_change()
+ monthly.iloc[0] = monthly_end.iloc[0] - 1.0
avg_total = frame["total"].mean()
- turnover_per_year = extra["turnover_value"] / max(avg_total, 1) / n_years if n_years > 0 else 0.0
+ turnover_per_year = (
+ extra["turnover_value"] / max(avg_total, 1) / n_years if n_years > 0 else 0.0
+ )
final = frame["total"].iloc[-1]
contributed = frame["contributed"].iloc[-1]
return {
"years": round(n_years, 2),
"cagr_pct": round(cagr * 100, 2),
+ "period_return_pct": round((float(twr.iloc[-1]) - 1) * 100, 2),
"vol_pct": round(vol * 100, 2),
"sharpe": round(float(sharpe), 2),
"mdd_pct": round(float(mdd) * 100, 2),
- "calmar": round(float(calmar), 2) if calmar == calmar else None,
+ "calmar": round(float(calmar), 2) if math.isfinite(calmar) else None,
"worst_year_pct": round(float(yearly.min()) * 100, 2),
"losing_years": int((yearly < 0).sum()),
- "total_years": int(len(yearly)),
+ "total_years": len(yearly),
"negative_months_pct": round(float((monthly < 0).mean()) * 100, 1),
"avg_exposure_pct": round(extra["avg_exposure"] * 100, 1),
"turnover_per_year_pct": round(turnover_per_year * 100, 1),
@@ -260,34 +376,61 @@ def run_pocket(start="2002-01-01", use_etf=False):
results = {}
frames = {}
for policy in POLICIES:
- frame, extra = simulate(closes, policy, index_closes=index, target_stock=0.5,
- initial=300_000, monthly=100_000, tax_rate=0.0)
+ frame, extra = simulate(
+ closes,
+ policy,
+ index_closes=index,
+ target_stock=0.5,
+ initial=300_000,
+ monthly=100_000,
+ tax_rate=0.0,
+ )
results[policy.name] = {"label": policy.label, **metrics(frame, extra)}
frames[policy.name] = frame
- return {"symbol": symbol, "start": str(closes.index[0].date()), "end": str(closes.index[-1].date()),
- "results": results}, frames
+ return {
+ "symbol": symbol,
+ "start": str(closes.index[0].date()),
+ "end": str(closes.index[-1].date()),
+ "results": results,
+ }, frames
def run_basket(start="2021-12-01"):
"""트랙 2: 대형주 10종목 동일비중을 하나의 위험자산(EW 지수)으로 묶고, 주식 비중 60%."""
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) # 동일비중 지수 (일별 리밸런싱 근사 없음: 첫날 동일비중 보유)
+ ew = (panel / panel.iloc[0]).mean(
+ axis=1
+ ) # 동일비중 지수 (일별 리밸런싱 근사 없음: 첫날 동일비중 보유)
# 첫날 동일비중 매수 후 보유한 가치가 정확히 위 식이다 (수량 고정, 가격만 변동).
index = _fdr("KS200", start)
results = {}
frames = {}
for policy in POLICIES:
- frame, extra = simulate(ew, policy, index_closes=index, target_stock=0.6,
- initial=10_000_000, monthly=0.0, tax_rate=STOCK_TAX)
+ frame, extra = simulate(
+ ew,
+ policy,
+ index_closes=index,
+ target_stock=0.6,
+ initial=10_000_000,
+ monthly=0.0,
+ tax_rate=STOCK_TAX,
+ )
results[policy.name] = {"label": policy.label, **metrics(frame, extra)}
frames[policy.name] = frame
- return {"symbol": "EW10", "symbols": BASKET_SYMBOLS, "start": str(ew.index[0].date()),
- "end": str(ew.index[-1].date()), "results": results}, frames
+ return {
+ "symbol": "EW10",
+ "symbols": BASKET_SYMBOLS,
+ "start": str(ew.index[0].date()),
+ "end": str(ew.index[-1].date()),
+ "results": results,
+ }, frames
def _plot(track_name, summary, frames, out_png, title):
import matplotlib
+
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager
@@ -299,8 +442,17 @@ def _plot(track_name, summary, frames, out_png, title):
plt.rcParams["axes.unicode_minus"] = False
paper, ink, faint, up, down = "#f2f0ec", "#0d0c0b", "#8c8983", "#c33d2b", "#2557c9"
order = ["static", "trend50", "dd10", "trend50_dd10", "vol20", "trend0"]
- colors = {"static": faint, "trend50": ink, "dd10": up, "trend50_dd10": down, "vol20": "#b06d12", "trend0": "#6b5d9e"}
- fig, axes = plt.subplots(2, 1, figsize=(11, 7.2), sharex=True, gridspec_kw={"height_ratios": [2.4, 1]})
+ colors = {
+ "static": faint,
+ "trend50": ink,
+ "dd10": up,
+ "trend50_dd10": down,
+ "vol20": "#b06d12",
+ "trend0": "#6b5d9e",
+ }
+ fig, axes = plt.subplots(
+ 2, 1, figsize=(11, 7.2), sharex=True, gridspec_kw={"height_ratios": [2.4, 1]}
+ )
fig.patch.set_facecolor(paper)
for ax in axes:
ax.set_facecolor(paper)
@@ -313,14 +465,33 @@ def _plot(track_name, summary, frames, out_png, title):
continue
f = frames[name]
lw = 1.6 if name in ("static", "trend50", "dd10", "trend50_dd10") else 1.0
- axes[0].plot(f.index, f["twr"] * 100, color=colors[name], linewidth=lw, label=f"{summary['results'][name]['label']} · 연 {summary['results'][name]['cagr_pct']:.1f}% · 낙폭 {summary['results'][name]['mdd_pct']:.0f}%")
- axes[1].fill_between(f.index, f["drawdown"] * 100, 0, color=colors[name], alpha=0.12 if name != "static" else 0.25, linewidth=0)
+ axes[0].plot(
+ f.index,
+ f["twr"] * 100,
+ color=colors[name],
+ linewidth=lw,
+ label=f"{summary['results'][name]['label']} · 연 {summary['results'][name]['cagr_pct']:.1f}% · 낙폭 {summary['results'][name]['mdd_pct']:.0f}%",
+ )
+ axes[1].fill_between(
+ f.index,
+ f["drawdown"] * 100,
+ 0,
+ color=colors[name],
+ alpha=0.12 if name != "static" else 0.25,
+ linewidth=0,
+ )
axes[1].plot(f.index, f["drawdown"] * 100, color=colors[name], linewidth=0.9)
axes[0].set_title(title, loc="left", fontsize=13, color=ink, pad=12)
axes[0].set_ylabel("시간가중 지수 (시작=100)", fontsize=9, color=ink)
axes[0].legend(loc="upper left", fontsize=8.5, frameon=False)
axes[1].set_ylabel("고점 대비 낙폭 (%)", fontsize=9, color=ink)
- fig.text(0.01, 0.01, "비용 반영(수수료·슬리피지·주식 거래세) · 적립은 시간가중으로 분리 · 모든 신호는 전일 정보만 사용", fontsize=8, color=faint)
+ fig.text(
+ 0.01,
+ 0.01,
+ "비용 반영(수수료·슬리피지·주식 거래세) · 적립은 시간가중으로 분리 · 모든 신호는 전일 정보만 사용",
+ fontsize=8,
+ color=faint,
+ )
fig.tight_layout(rect=(0, 0.03, 1, 1))
out_png.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_png, dpi=150, facecolor=paper)
@@ -328,20 +499,36 @@ def _plot(track_name, summary, frames, out_png, title):
def _markdown(track_title, summary):
- lines = [f"### {track_title}", "", f"- 데이터: {summary['symbol']} {summary['start']} → {summary['end']}", "",
- "| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |",
- "|---|---:|---:|---:|---:|---:|---:|---:|---:|"]
- for name, r in summary["results"].items():
- lines.append(f"| {r['label']} | {r['cagr_pct']:+.2f}% | {r['mdd_pct']:.1f}% | {r['sharpe']:.2f} | {r['calmar'] if r['calmar'] is not None else '—'} | {r['worst_year_pct']:+.1f}% | {r['losing_years']}/{r['total_years']} | {r['avg_exposure_pct']:.0f}% | {r['turnover_per_year_pct']:.0f}% |")
+ lines = [
+ f"### {track_title}",
+ "",
+ f"- 데이터: {summary['symbol']} {summary['start']} → {summary['end']}",
+ "",
+ "| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |",
+ "|---|---:|---:|---:|---:|---:|---:|---:|---:|",
+ ]
+ for r in summary["results"].values():
+ lines.append(
+ f"| {r['label']} | {r['cagr_pct']:+.2f}% | {r['mdd_pct']:.1f}% | {r['sharpe']:.2f} | {r['calmar'] if r['calmar'] is not None else '—'} | {r['worst_year_pct']:+.1f}% | {r['losing_years']}/{r['total_years']} | {r['avg_exposure_pct']:.0f}% | {r['turnover_per_year_pct']:.0f}% |"
+ )
return "\n".join(lines)
def main():
+ global AS_OF
parser = argparse.ArgumentParser()
- parser.add_argument("--track", choices=["all", "pocket", "pocket_etf", "basket"], default="all")
+ parser.add_argument(
+ "--as-of", default=AS_OF, help="이 날짜 미만의 확정 일봉만 사용 (YYYY-MM-DD)"
+ )
+ parser.add_argument(
+ "--track", choices=["all", "pocket", "pocket_etf", "basket"], default="all"
+ )
parser.add_argument("--out-dir", default=str(_ROOT / "reports" / "research"))
parser.add_argument("--image-dir", default=str(_ROOT / "docs" / "images"))
args = parser.parse_args()
+ from datetime import date
+
+ AS_OF = date.fromisoformat(args.as_of).isoformat()
out_dir = Path(args.out_dir)
image_dir = Path(args.image_dir)
out_dir.mkdir(parents=True, exist_ok=True)
@@ -351,22 +538,39 @@ def main():
if args.track in ("all", "pocket"):
summary, frames = run_pocket("2002-01-01", use_etf=False)
report["pocket_ks200"] = summary
- _plot("pocket", summary, frames, image_dir / "overlay-pocket-ks200.png",
- "적립 트랙 · 코스피200 지수 50% + 현금성 50% · 2002년부터 월 10만원 적립")
+ _plot(
+ "pocket",
+ summary,
+ frames,
+ image_dir / "overlay-pocket-ks200.png",
+ "적립 트랙 · 코스피200 지수 50% + 현금성 50% · 2002년부터 월 10만원 적립",
+ )
md += [_markdown("적립 트랙 (KS200 지수, 2002~)", summary), ""]
if args.track in ("all", "pocket_etf"):
summary, frames = run_pocket("2014-01-01", use_etf=True)
report["pocket_etf"] = summary
- _plot("pocket_etf", summary, frames, image_dir / "overlay-pocket-etf.png",
- "적립 트랙 · KODEX 200 실제 ETF 50% + 현금성 50% · 2014년부터 월 10만원 적립")
+ _plot(
+ "pocket_etf",
+ summary,
+ frames,
+ image_dir / "overlay-pocket-etf.png",
+ "적립 트랙 · KODEX 200 실제 ETF 50% + 현금성 50% · 2014년부터 월 10만원 적립",
+ )
md += [_markdown("적립 트랙 (KODEX 200 ETF, 2014~)", summary), ""]
if args.track in ("all", "basket"):
summary, frames = run_basket("2021-12-01")
report["basket"] = summary
- _plot("basket", summary, frames, image_dir / "overlay-basket.png",
- "관찰 트랙 · 대형주 10종목 동일비중 60% + 현금 40% · 2022년 약세장 포함")
+ _plot(
+ "basket",
+ summary,
+ frames,
+ image_dir / "overlay-basket.png",
+ "관찰 트랙 · 대형주 10종목 동일비중 60% + 현금 40% · 2022년 약세장 포함",
+ )
md += [_markdown("관찰 트랙 (대형주 10종목, 2021-12~)", summary), ""]
- (out_dir / "risk_overlay_backtest.json").write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8")
+ (out_dir / "risk_overlay_backtest.json").write_text(
+ json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8"
+ )
(out_dir / "risk_overlay_backtest.md").write_text("\n".join(md), encoding="utf-8")
print("\n".join(md))
diff --git a/tools/risk_review.py b/tools/risk_review.py
new file mode 100644
index 0000000..f4b1dd5
--- /dev/null
+++ b/tools/risk_review.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+"""2026-09 위험 관리 검증. 주문 없이 과거 종가와 격리된 가상 계좌만 사용한다.
+
+고정한 정책을 기간별·비용별로 비교한다. 미래에 수집될 자료를 확보한 실험이 아니므로
+이 결과를 독립적인 미사용 표본 검증이나 향후 수익 보장으로 해석하지 않는다.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import sys
+from pathlib import Path
+
+import pandas as pd
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from core.risk_overlays import (
+ compute_decision,
+ overlay_target_weights,
+ parse_overlay_config,
+)
+from tools import risk_overlay_backtest as research
+
+
+def integer_etf_simulation(
+ panel,
+ policy,
+ *,
+ redirect=True,
+ cost_multiple=1.0,
+ initial=300_000.0,
+ monthly=100_000.0,
+ min_trade=50_000.0,
+):
+ """ETF 1주 단위, 다음 거래일 종가, 현금 5%, 편도 회전 상한 60%로 비교.
+
+ 실제 주문 엔진의 호가·체결 지연·괴리율은 재현하지 않는다. CD ETF는 양의 매매차익에
+ 15.4%를 차감하는 상한 근사(과표기준가 미확보), KODEX 200 분배금은 미반영이다.
+ """
+ cfg = parse_overlay_config(
+ {
+ "overlays": {
+ "combination": policy.combination,
+ "trend_filter": {
+ "enabled": policy.trend,
+ "ma_days": policy.trend_ma_days,
+ "band": policy.trend_band,
+ },
+ "drawdown_guard": {
+ "enabled": policy.dd,
+ "trigger": policy.dd_trigger,
+ "release": policy.dd_release,
+ "scale": policy.dd_scale,
+ },
+ }
+ }
+ )
+ symbols = ["069500", "357870"]
+ quantity = {s: 0 for s in symbols}
+ avg = {s: 0.0 for s in symbols}
+ cash = initial
+ contributed = initial
+ previous_total = initial
+ twr = peak = 1.0
+ previous_state = None
+ previous_month = None
+ rows = []
+ # 200日 선 계산 기간은 성과 집계 전에 따로 확보한다.
+ start = max(200, panel.index.get_indexer([panel[symbols].dropna().index[0]])[0])
+ commission = research.COMMISSION * cost_multiple
+ slippage = research.SLIPPAGE * cost_multiple
+ for i in range(start, len(panel)):
+ day = panel.index[i]
+ prices = {s: float(panel[s].iloc[i]) for s in symbols}
+ if any(not math.isfinite(p) or p <= 0 for p in prices.values()):
+ raise ValueError("ETF 종가가 누락됐습니다")
+ month = (day.year, day.month)
+ flow = (
+ monthly if previous_month is not None and month != previous_month else 0.0
+ )
+ cash += flow
+ contributed += flow
+ previous_month = month
+ decision = compute_decision(
+ cfg,
+ index_closes=panel["KS200"].iloc[:i].tolist(),
+ cumulative_returns_pct=[(peak - 1) * 100, (twr - 1) * 100],
+ prev_state=previous_state,
+ now=day.to_pydatetime(),
+ )
+ previous_state = decision.to_dict()
+ targets = overlay_target_weights(
+ {s: 0.5 for s in symbols},
+ 0.95,
+ decision.scale,
+ "357870" if redirect else None,
+ )
+ total_before = cash + sum(quantity[s] * prices[s] for s in symbols)
+ invested_fraction = sum(targets.values())
+ sleeve = total_before * invested_fraction
+ invested = total_before - cash
+ drift = (
+ max(
+ abs(quantity[s] * prices[s] / sleeve - targets[s] / invested_fraction)
+ for s in symbols
+ )
+ if sleeve > 0
+ else 0.0
+ )
+ need = (
+ i == start
+ or drift >= 0.08
+ or abs(invested / total_before - invested_fraction) >= 0.03
+ )
+ trade_value = costs = 0.0
+ budget = total_before * 0.6
+ if need:
+ # 매도 후 매수. 목표 미달 1주를 억지로 올려 사지 않는다.
+ diffs = {
+ s: total_before * targets[s] - quantity[s] * prices[s] for s in symbols
+ }
+ for s in sorted(symbols, key=lambda symbol: diffs[symbol]):
+ price, diff = prices[s], diffs[s]
+ qty = int(abs(diff) / price)
+ if qty <= 0 or qty * price < min_trade:
+ continue
+ fill = price * (1 + slippage if diff > 0 else 1 - slippage)
+ qty = min(qty, int(budget / fill))
+ if diff < 0:
+ qty = min(qty, quantity[s])
+ else:
+ available = max(0.0, cash - total_before * 0.05)
+ qty = min(qty, int(available / (fill * (1 + commission))))
+ if qty <= 0 or qty * fill < min_trade:
+ continue
+ notional = qty * fill
+ cost = notional * commission
+ if diff < 0:
+ if s == "357870":
+ cost += max(0.0, fill - avg[s]) * qty * 0.154
+ quantity[s] -= qty
+ cash += notional - cost
+ else:
+ avg[s] = (avg[s] * quantity[s] + notional + cost) / (
+ quantity[s] + qty
+ )
+ quantity[s] += qty
+ cash -= notional + cost
+ budget -= notional
+ trade_value += notional
+ costs += cost
+ total = cash + sum(quantity[s] * prices[s] for s in symbols)
+ daily_return = total / (previous_total + flow) - 1.0
+ twr *= 1 + daily_return
+ peak = max(peak, twr)
+ previous_total = total
+ rows.append(
+ {
+ "date": day,
+ "total": total,
+ "twr": twr,
+ "drawdown": twr / peak - 1,
+ "daily_return": daily_return,
+ "flow": flow,
+ "contributed": contributed,
+ "cash": cash,
+ "stock_w": quantity["069500"] * prices["069500"] / total,
+ "scale": decision.scale,
+ "trade_value": trade_value,
+ "cost": costs,
+ **{f"qty_{s}": quantity[s] for s in symbols},
+ }
+ )
+ frame = pd.DataFrame(rows).set_index("date")
+ return frame, {
+ "turnover_value": frame.trade_value.sum(),
+ "avg_exposure": frame.stock_w.mean(),
+ }
+
+
+def period_metrics(frame, start, end):
+ part = frame.loc[start:end].copy()
+ if len(part) < 2:
+ return None
+ part["twr"] = (1 + part.daily_return).cumprod()
+ part["drawdown"] = part.twr / part.twr.cummax().clip(lower=1) - 1
+ extra = {
+ "turnover_value": part.trade_value.sum(),
+ "avg_exposure": part.stock_w.mean(),
+ }
+ return research.metrics(part, extra)
+
+
+def plot_comparison(frames, path):
+ import matplotlib
+
+ matplotlib.use("Agg")
+ from matplotlib import font_manager
+ from matplotlib import pyplot as plt
+
+ for name in ["Malgun Gothic", "Noto Sans CJK KR", "Apple SD Gothic Neo"]:
+ if any(f.name == name for f in font_manager.fontManager.ttflist):
+ plt.rcParams["font.family"] = name
+ break
+ plt.rcParams["axes.unicode_minus"] = False
+ fig, ax = plt.subplots(
+ 2, 1, figsize=(11, 6.6), sharex=True, gridspec_kw={"height_ratios": [2, 1]}
+ )
+ names = {
+ "static": "고정 비중",
+ "old_product": "기존 방식 (TWR 오류 수정)",
+ "minimum": "주식만 조절·중복 축소 방지",
+ }
+ colors = {"static": "#9aa5b5", "old_product": "#8e6853", "minimum": "#3157a4"}
+ for key, f in frames.items():
+ if key not in names:
+ continue
+ ax[0].plot(f.index, f.twr * 100, label=names[key], color=colors[key], lw=1.8)
+ ax[1].plot(f.index, f.drawdown * 100, color=colors[key], lw=1.2)
+ ax[0].set_title("ETF 적립 계좌의 수익과 하락 구간", loc="left", fontsize=16, pad=17)
+ ax[0].set_ylabel("시간가중 지수\n시작 = 100", fontsize=10)
+ ax[1].set_ylabel("고점 대비 하락률 (%)", fontsize=10)
+ ax[0].legend(frameon=False, fontsize=9, loc="upper left")
+ for a in ax:
+ a.spines[["top", "right"]].set_visible(False)
+ a.spines[["left", "bottom"]].set_color("#cbd1dd")
+ a.grid(axis="y", alpha=0.12)
+ a.tick_params(labelsize=9, colors="#596579")
+ fig.text(
+ 0.07,
+ 0.025,
+ "실제 ETF 종가 · 1주 단위 · 월 10만원 적립 · 수수료·슬리피지 반영 · 과거 성과이며 미래 수익을 보장하지 않음",
+ fontsize=9,
+ color="#596579",
+ )
+ fig.tight_layout(rect=(0, 0.05, 1, 1))
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(path, dpi=150, facecolor="white")
+ plt.close(fig)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--as-of", default="2026-09-17")
+ parser.add_argument(
+ "--output", default="reports/research/risk_review_20260917.json"
+ )
+ parser.add_argument("--image", default="docs/images/risk-review-20260917.png")
+ args = parser.parse_args()
+ research.AS_OF = args.as_of
+ series = {s: research._fdr(s, "2014-01-01") for s in ["069500", "357870", "KS200"]}
+ panel = pd.DataFrame(series).loc[: args.as_of]
+ policies = {
+ "static": research.Policy("static", "고정 비중"),
+ "old_product": research.Policy("old_product", "기존 방식", trend=True, dd=True),
+ "minimum": research.Policy(
+ "minimum",
+ "주식만 조절·중복 축소 방지",
+ trend=True,
+ dd=True,
+ combination="minimum",
+ ),
+ }
+ frames = {}
+ result = {}
+ for name, policy in policies.items():
+ f, extra = integer_etf_simulation(panel, policy, redirect=name != "old_product")
+ frames[name] = f
+ result[name] = {
+ "label": policy.label,
+ "all": research.metrics(f, extra),
+ "periods": {
+ label: period_metrics(f, a, b)
+ for label, a, b in [
+ ("2020_2022", "2020-01-01", "2022-12-31"),
+ ("2023_2025", "2023-01-01", "2025-12-31"),
+ ("2026", "2026-01-01", args.as_of),
+ ]
+ },
+ }
+ stressed, ex = integer_etf_simulation(
+ panel, policy, redirect=name != "old_product", cost_multiple=3
+ )
+ result[name]["triple_cost"] = research.metrics(stressed, ex)
+ # 소수 주 장기 연구: 고정된 정책으로 구간을 나눠 보고, 금리 0%에서도 비교한다.
+ long_run = {}
+ for name, policy in policies.items():
+ f, extra = research.simulate(
+ series["069500"], policy, index_closes=series["KS200"]
+ )
+ zero, ex = research.simulate(
+ series["069500"], policy, index_closes=series["KS200"], rf_annual=0
+ )
+ long_run[name] = {
+ "all": research.metrics(f, extra),
+ "zero_cash_yield": research.metrics(zero, ex),
+ "periods": {
+ label: period_metrics(f, a, b)
+ for label, a, b in [
+ ("2014_2018", "2014-01-01", "2018-12-31"),
+ ("2019_2022", "2019-01-01", "2022-12-31"),
+ ("2023_2026", "2023-01-01", args.as_of),
+ ]
+ },
+ }
+ manifest = {
+ s: {
+ "first": str(v.index[0].date()),
+ "last": str(v.index[-1].date()),
+ "rows": len(v),
+ "sha256": hashlib.sha256(v.to_csv().encode()).hexdigest(),
+ }
+ for s, v in series.items()
+ }
+ payload = {
+ "as_of": args.as_of,
+ "last_complete_bar": str(frames["static"].index[-1].date()),
+ "integer_start": str(frames["static"].index[0].date()),
+ "source": "FinanceDataReader, close prices",
+ "data": manifest,
+ "integer_etf": result,
+ "fractional_research": long_run,
+ "limitations": [
+ "동일 기간을 이미 살펴본 사후 검증이며 독립적인 미사용 표본이 아님",
+ "ETF 분배금 미포함; 현금 이자 0%, CD ETF 양의 매매차익 15.4% 상한 과세",
+ "다음 거래일 종가에 비용을 더한 근사 체결; 실시간 호가·괴리율·미체결 미재현",
+ "소수 주 연구의 현금금리는 연 3% 고정 가정; 금리 0% 민감도도 공개",
+ "부분 연도는 연도 전체 수익률이 아님; 실전 자동 전환 없음",
+ ],
+ }
+ target = Path(args.output)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False),
+ encoding="utf-8",
+ )
+ plot_comparison(frames, Path(args.image))
+ print(
+ json.dumps(
+ {
+ "integer_start": payload["integer_start"],
+ "last": payload["last_complete_bar"],
+ "integer_etf": result,
+ "fractional": long_run,
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()