Files
strategy32/scripts/run_cash_overlay_search.py

437 lines
19 KiB
Python

from __future__ import annotations
import itertools
import json
import sys
from dataclasses import asdict
from pathlib import Path
import pandas as pd
PACKAGE_PARENT = Path(__file__).resolve().parents[2]
if str(PACKAGE_PARENT) not in sys.path:
sys.path.insert(0, str(PACKAGE_PARENT))
from strategy29.backtest.window_analysis import slice_bundle
from strategy32.backtest.simulator import Strategy32Backtester
from strategy32.config import PROFILE_V7_DEFAULT, build_strategy32_config
from strategy32.research.hybrid_regime import STATIC_FILTERS
from strategy32.research.soft_router import (
WINDOWS,
YEAR_PERIODS,
YTD_START,
CashOverlayCandidate,
build_cash_overlay_period_components,
compose_cash_overlay_curve,
evaluate_cash_overlay_exact,
load_component_bundle,
score_candidate,
segment_metrics,
)
OUT_JSON = Path("/tmp/strategy32_cash_overlay_search.json")
OUT_MD = Path("/Volumes/SSD/data/nextcloud/data/tara/files/📂HeadOffice/money-bot/strategy32/017_cash_overlay_탐색결과.md")
SOFT_JSON = Path("/tmp/strategy32_best_soft_exact.json")
PROFILE = "loose_positive"
CORE_FILTER = "overheat_tolerant"
CAP_ENGINE = "cap_btc_rebound"
CHOP_ENGINE = "chop_inverse_carry_strict"
DIST_ENGINE = "dist_inverse_carry_strict"
STATIC_BASELINE = {
"name": "overheat_tolerant",
"windows": {
"1y": {"total_return": 0.1477, "annualized_return": 0.1477, "max_drawdown": -0.1229},
"2y": {"total_return": 0.2789, "annualized_return": 0.1309, "max_drawdown": -0.1812},
"3y": {"total_return": 0.4912, "annualized_return": 0.1425, "max_drawdown": -0.1931},
"4y": {"total_return": 0.3682, "annualized_return": 0.0815, "max_drawdown": -0.3461},
"5y": {"total_return": 3.7625, "annualized_return": 0.3664, "max_drawdown": -0.2334},
},
"years": {
"2026_YTD": {"total_return": 0.0, "max_drawdown": 0.0},
"2025": {"total_return": 0.0426, "max_drawdown": -0.1323},
"2024": {"total_return": 0.1951, "max_drawdown": -0.2194},
"2023": {"total_return": 0.4670, "max_drawdown": -0.2155},
"2022": {"total_return": 0.0147, "max_drawdown": -0.0662},
"2021": {"total_return": 1.9152, "max_drawdown": -0.1258},
},
}
EXPOSURE_SUMMARY = {
"avg_cash_pct": 0.9379,
"median_cash_pct": 1.0,
"cash_gt_50_pct": 0.9469,
"cash_gt_80_pct": 0.9068,
"avg_momentum_pct": 0.0495,
"avg_carry_pct": 0.0126,
}
CAP_CASH_WEIGHTS = (0.20, 0.35, 0.50, 0.65)
CHOP_CASH_WEIGHTS = (0.10, 0.20, 0.30, 0.40)
DIST_CASH_WEIGHTS = (0.05, 0.10, 0.15, 0.20)
CAP_THRESHOLDS = (0.20, 0.35, 0.50)
CHOP_THRESHOLDS = (0.35, 0.50, 0.65)
DIST_THRESHOLDS = (0.35, 0.50, 0.65)
CORE_BLOCK_THRESHOLDS = (0.45, 0.60, 0.75)
def _evaluate_from_curve(curve: pd.Series, latest_bar: pd.Timestamp) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]], float, int, int]:
window_results = {
label: segment_metrics(curve, latest_bar - pd.Timedelta(days=days), latest_bar)
for days, label in WINDOWS
}
year_results = {
label: segment_metrics(curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)))
for label, start, end_exclusive in YEAR_PERIODS
}
year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar)
score, negative_years, mdd_violations = score_candidate(
window_results,
{k: v for k, v in year_results.items() if k != "2026_YTD"},
)
return window_results, year_results, score, negative_years, mdd_violations
def _exact_static_variant(bundle, latest_bar: pd.Timestamp, filter_name: str) -> dict[str, object]:
window_results: dict[str, dict[str, float]] = {}
year_results: dict[str, dict[str, float]] = {}
for days, label in WINDOWS:
eval_start = latest_bar - pd.Timedelta(days=days)
raw_start = eval_start - pd.Timedelta(days=90)
sliced = slice_bundle(bundle, raw_start, latest_bar)
cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name])
backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start)
backtester.engine_config.initial_capital = 1000.0
curve = backtester.run().equity_curve.loc[lambda s: s.index >= eval_start]
window_results[label] = segment_metrics(curve, eval_start, latest_bar)
for label, start, end_exclusive in YEAR_PERIODS:
eval_end = min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))
raw_start = start - pd.Timedelta(days=90)
sliced = slice_bundle(bundle, raw_start, eval_end)
cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name])
backtester = Strategy32Backtester(cfg, sliced, trade_start=start)
backtester.engine_config.initial_capital = 1000.0
curve = backtester.run().equity_curve.loc[lambda s: s.index >= start]
year_results[label] = segment_metrics(curve, start, eval_end)
raw_start = YTD_START - pd.Timedelta(days=90)
sliced = slice_bundle(bundle, raw_start, latest_bar)
cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name])
backtester = Strategy32Backtester(cfg, sliced, trade_start=YTD_START)
backtester.engine_config.initial_capital = 1000.0
curve = backtester.run().equity_curve.loc[lambda s: s.index >= YTD_START]
year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar)
score, negative_years, mdd_violations = score_candidate(
window_results,
{k: v for k, v in year_results.items() if k != "2026_YTD"},
)
return {
"name": filter_name,
"windows": window_results,
"years": year_results,
"score": score,
"negative_years": negative_years,
"mdd_violations": mdd_violations,
"validation": "exact_static_variant",
}
def _core_exposure_summary(bundle, latest_bar: pd.Timestamp) -> dict[str, float]:
eval_start = latest_bar - pd.Timedelta(days=1825)
raw_start = eval_start - pd.Timedelta(days=90)
sliced = slice_bundle(bundle, raw_start, latest_bar)
cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[CORE_FILTER])
backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start)
backtester.engine_config.initial_capital = 1000.0
result = backtester.run()
exposure_frame = pd.DataFrame(result.metadata.get("exposure_rows", []))
exposure_frame = exposure_frame.loc[exposure_frame["timestamp"] >= eval_start].copy()
return {
"avg_cash_pct": float(exposure_frame["cash_pct"].mean()),
"median_cash_pct": float(exposure_frame["cash_pct"].median()),
"cash_gt_50_pct": float((exposure_frame["cash_pct"] > 0.50).mean()),
"cash_gt_80_pct": float((exposure_frame["cash_pct"] > 0.80).mean()),
"avg_momentum_pct": float(exposure_frame["momentum_pct"].mean()),
"avg_carry_pct": float(exposure_frame["carry_pct"].mean()),
}
def _metric_line(metrics: dict[str, float], *, include_ann: bool) -> str:
sharpe = metrics.get("sharpe")
if include_ann:
parts = [
f"ret `{metrics['total_return'] * 100:.2f}%`",
f"ann `{metrics['annualized_return'] * 100:.2f}%`",
]
else:
parts = [f"ret `{metrics['total_return'] * 100:.2f}%`"]
if sharpe is not None:
parts.append(f"sharpe `{sharpe:.2f}`")
parts.append(f"mdd `{metrics['max_drawdown'] * 100:.2f}%`")
return ", ".join(parts)
def main() -> None:
bundle, latest_bar = load_component_bundle()
eval_start = latest_bar - pd.Timedelta(days=1825)
static_exact = STATIC_BASELINE
exposure_summary = EXPOSURE_SUMMARY
print("[stage] build 5y overlay components", flush=True)
components = build_cash_overlay_period_components(
bundle=bundle,
eval_start=eval_start,
eval_end=latest_bar,
profile_name=PROFILE,
core_filter=CORE_FILTER,
cap_engine=CAP_ENGINE,
chop_engine=CHOP_ENGINE,
dist_engine=DIST_ENGINE,
)
print("[stage] begin approximate candidate search", flush=True)
candidates = [
CashOverlayCandidate(
regime_profile=PROFILE,
core_filter=CORE_FILTER,
cap_engine=CAP_ENGINE,
chop_engine=CHOP_ENGINE,
dist_engine=DIST_ENGINE,
cap_cash_weight=cap_cash_weight,
chop_cash_weight=chop_cash_weight,
dist_cash_weight=dist_cash_weight,
cap_threshold=cap_threshold,
chop_threshold=chop_threshold,
dist_threshold=dist_threshold,
core_block_threshold=core_block_threshold,
)
for (
cap_cash_weight,
chop_cash_weight,
dist_cash_weight,
cap_threshold,
chop_threshold,
dist_threshold,
core_block_threshold,
) in itertools.product(
CAP_CASH_WEIGHTS,
CHOP_CASH_WEIGHTS,
DIST_CASH_WEIGHTS,
CAP_THRESHOLDS,
CHOP_THRESHOLDS,
DIST_THRESHOLDS,
CORE_BLOCK_THRESHOLDS,
)
]
approx_rows: list[dict[str, object]] = []
static_1y_ann = float(static_exact["windows"]["1y"]["annualized_return"])
static_5y_ann = float(static_exact["windows"]["5y"]["annualized_return"])
static_5y_mdd = float(static_exact["windows"]["5y"]["max_drawdown"])
for idx, candidate in enumerate(candidates, start=1):
curve, weights = compose_cash_overlay_curve(candidate=candidate, **components)
window_results, year_results, score, negative_years, mdd_violations = _evaluate_from_curve(curve, latest_bar)
beat_static_flags = {
"1y_ann": float(window_results["1y"]["annualized_return"]) > static_1y_ann,
"5y_ann": float(window_results["5y"]["annualized_return"]) > static_5y_ann,
"5y_mdd": float(window_results["5y"]["max_drawdown"]) >= static_5y_mdd,
}
approx_rows.append(
{
"candidate": asdict(candidate),
"name": candidate.name,
"score": score,
"negative_years": negative_years,
"mdd_violations": mdd_violations,
"windows": window_results,
"years": year_results,
"avg_weights": {
"cap": float(weights["cap_weight"].mean()),
"chop": float(weights["chop_weight"].mean()),
"dist": float(weights["dist_weight"].mean()),
"overlay_total": float(weights["overlay_total"].mean()),
"core_cash_pct": float(weights["core_cash_pct"].mean()),
},
"beat_static": beat_static_flags,
"validation": "approx_full_curve_slice_cash_overlay",
}
)
if idx % 500 == 0 or idx == len(candidates):
print(
f"[approx {idx:04d}/{len(candidates)}] "
f"1y={window_results['1y']['total_return'] * 100:.2f}% "
f"5y_ann={window_results['5y']['annualized_return'] * 100:.2f}%",
flush=True,
)
approx_rows.sort(
key=lambda row: (
int(not row["beat_static"]["5y_ann"]),
int(not row["beat_static"]["1y_ann"]),
int(row["negative_years"]),
int(row["mdd_violations"]),
-float(row["score"]),
)
)
exact_top: list[dict[str, object]] = []
print("[stage] begin exact validation for top candidates", flush=True)
for row in approx_rows[:5]:
candidate = CashOverlayCandidate(**row["candidate"])
print(f"[exact-start] {candidate.name}", flush=True)
result = evaluate_cash_overlay_exact(bundle=bundle, latest_bar=latest_bar, candidate=candidate)
exact_top.append(result)
print(
f"[exact] {candidate.name} 1y={result['windows']['1y']['total_return'] * 100:.2f}% "
f"5y_ann={result['windows']['5y']['annualized_return'] * 100:.2f}% "
f"neg={result['negative_years']} mdd_viol={result['mdd_violations']}",
flush=True,
)
exact_top.sort(
key=lambda row: (
int(float(row["windows"]["5y"]["annualized_return"]) <= static_5y_ann),
int(float(row["windows"]["1y"]["annualized_return"]) <= static_1y_ann),
int(row["negative_years"]),
int(row["mdd_violations"]),
-float(row["score"]),
)
)
best_exact = exact_top[0]
soft_exact = json.loads(SOFT_JSON.read_text(encoding="utf-8")) if SOFT_JSON.exists() else None
payload = {
"analysis": "strategy32_cash_overlay_search",
"latest_completed_bar": str(latest_bar),
"candidate_count": len(candidates),
"core_filter": CORE_FILTER,
"engines": {
"cap": CAP_ENGINE,
"chop": CHOP_ENGINE,
"dist": DIST_ENGINE,
},
"exposure_summary": exposure_summary,
"static_exact": static_exact,
"summary": approx_rows[:20],
"exact_top": exact_top,
"best_exact": best_exact,
"best_soft_exact": soft_exact,
}
print("[stage] write outputs", flush=True)
OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8")
lines = [
"# Strategy32 Cash Overlay 탐색결과",
"",
"## 1. 목적",
"",
"정적 core를 줄이던 기존 soft-router를 버리고, `overheat_tolerant` core가 실제로 비워두는 현금 위에만 adverse 엔진을 얹는 cash-overlay 구조를 탐색한다.",
"",
"## 2. 왜 구조를 바꿨는가",
"",
f"- core `overheat_tolerant` 5y 평균 현금 비중: `{exposure_summary['avg_cash_pct'] * 100:.2f}%`",
f"- core 중앙값 현금 비중: `{exposure_summary['median_cash_pct'] * 100:.2f}%`",
f"- 현금 비중 `> 50%` 바 비율: `{exposure_summary['cash_gt_50_pct'] * 100:.2f}%`",
f"- 현금 비중 `> 80%` 바 비율: `{exposure_summary['cash_gt_80_pct'] * 100:.2f}%`",
"",
"즉 기존 soft-router는 이미 대부분 현금인 core를 또 줄이고 있었다. overlay는 core를 대체하는 게 아니라, core가 실제로 안 쓰는 현금에만 들어가야 맞다.",
"",
"## 3. 탐색 범위",
"",
f"- profile: `{PROFILE}`",
f"- core filter: `{CORE_FILTER}`",
f"- cap engine: `{CAP_ENGINE}`",
f"- chop engine: `{CHOP_ENGINE}`",
f"- dist engine: `{DIST_ENGINE}`",
f"- cap cash weights: `{CAP_CASH_WEIGHTS}`",
f"- chop cash weights: `{CHOP_CASH_WEIGHTS}`",
f"- dist cash weights: `{DIST_CASH_WEIGHTS}`",
f"- cap thresholds: `{CAP_THRESHOLDS}`",
f"- chop thresholds: `{CHOP_THRESHOLDS}`",
f"- dist thresholds: `{DIST_THRESHOLDS}`",
f"- core block thresholds: `{CORE_BLOCK_THRESHOLDS}`",
f"- candidate count: `{len(candidates)}`",
"",
"## 4. 정적 core exact 기준선",
"",
f"- 1y: {_metric_line(static_exact['windows']['1y'], include_ann=False)}",
f"- 2y: {_metric_line(static_exact['windows']['2y'], include_ann=True)}",
f"- 3y: {_metric_line(static_exact['windows']['3y'], include_ann=True)}",
f"- 4y: {_metric_line(static_exact['windows']['4y'], include_ann=True)}",
f"- 5y: {_metric_line(static_exact['windows']['5y'], include_ann=True)}",
f"- 2026 YTD: {_metric_line(static_exact['years']['2026_YTD'], include_ann=False)}",
f"- 2025: {_metric_line(static_exact['years']['2025'], include_ann=False)}",
f"- 2024: {_metric_line(static_exact['years']['2024'], include_ann=False)}",
f"- 2023: {_metric_line(static_exact['years']['2023'], include_ann=False)}",
f"- 2022: {_metric_line(static_exact['years']['2022'], include_ann=False)}",
f"- 2021: {_metric_line(static_exact['years']['2021'], include_ann=False)}",
"",
"## 5. cash-overlay exact 상위 후보",
"",
]
for idx, row in enumerate(exact_top, start=1):
candidate = row["candidate"]
lines.extend(
[
f"### {idx}. {row['name']}",
"",
f"- weights: `cap {candidate['cap_cash_weight']:.2f}`, `chop {candidate['chop_cash_weight']:.2f}`, `dist {candidate['dist_cash_weight']:.2f}`",
f"- thresholds: `cap {candidate['cap_threshold']:.2f}`, `chop {candidate['chop_threshold']:.2f}`, `dist {candidate['dist_threshold']:.2f}`, `block {candidate['core_block_threshold']:.2f}`",
f"- 1y: {_metric_line(row['windows']['1y'], include_ann=False)}",
f"- 2y: {_metric_line(row['windows']['2y'], include_ann=True)}",
f"- 3y: {_metric_line(row['windows']['3y'], include_ann=True)}",
f"- 4y: {_metric_line(row['windows']['4y'], include_ann=True)}",
f"- 5y: {_metric_line(row['windows']['5y'], include_ann=True)}",
f"- 2026 YTD: {_metric_line(row['years']['2026_YTD'], include_ann=False)}",
f"- 2025: {_metric_line(row['years']['2025'], include_ann=False)}",
f"- 2024: {_metric_line(row['years']['2024'], include_ann=False)}",
f"- 2023: {_metric_line(row['years']['2023'], include_ann=False)}",
f"- 2022: {_metric_line(row['years']['2022'], include_ann=False)}",
f"- 2021: {_metric_line(row['years']['2021'], include_ann=False)}",
f"- score `{row['score']:.3f}`, negative years `{row['negative_years']}`, mdd violations `{row['mdd_violations']}`",
"",
]
)
lines.extend(
[
"## 6. 결론",
"",
(
"cash-overlay가 정적 core보다 나아졌는지는 `best_exact`와 `static_exact` 비교로 판단한다. "
"핵심 비교 포인트는 `1y`, `5y annualized`, `5y MDD`, 그리고 `2025/2024`의 음수 여부다."
),
"",
f"- best cash-overlay 1y: `{best_exact['windows']['1y']['total_return'] * 100:.2f}%` vs static `{static_exact['windows']['1y']['total_return'] * 100:.2f}%`",
f"- best cash-overlay 5y ann: `{best_exact['windows']['5y']['annualized_return'] * 100:.2f}%` vs static `{static_exact['windows']['5y']['annualized_return'] * 100:.2f}%`",
f"- best cash-overlay 5y MDD: `{best_exact['windows']['5y']['max_drawdown'] * 100:.2f}%` vs static `{static_exact['windows']['5y']['max_drawdown'] * 100:.2f}%`",
"",
]
)
if soft_exact:
lines.extend(
[
"## 7. 기존 replacement soft-router와 비교",
"",
f"- previous soft 1y: `{soft_exact['windows']['1y']['total_return'] * 100:.2f}%`",
f"- previous soft 5y ann: `{soft_exact['windows']['5y']['annualized_return'] * 100:.2f}%`",
f"- previous soft 5y MDD: `{soft_exact['windows']['5y']['max_drawdown'] * 100:.2f}%`",
"",
]
)
OUT_MD.write_text("\n".join(lines), encoding="utf-8")
print("[done] cash overlay search complete", flush=True)
if __name__ == "__main__":
main()