437 lines
19 KiB
Python
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()
|