| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- from __future__ import annotations
- import argparse
- import sys
- from pathlib import Path
- import pandas as pd
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- from scripts import explore_ultrashort as explore
- from scripts import refine_expansion_mean_reversion_regime as mean_regime
- from scripts import refine_expansion_rotation_risk as rotation_risk
- from scripts.search_eth_btc_nextgen_variants import markdown_table
- from scripts.search_expansion_mean_reversion import ROUNDTRIP_TAKER_COST_ON_MARGIN, daily_equity, load_base_candles, resample_candles
- OUTPUT_DIR = Path("reports/strategy-expansion")
- INITIAL_EQUITY = 10_000.0
- HORIZONS = (
- ("3y", pd.DateOffset(years=3)),
- ("1y", pd.DateOffset(years=1)),
- ("6m", pd.DateOffset(months=6)),
- ("3m", pd.DateOffset(months=3)),
- )
- def daily_from_intraday(equity: pd.Series) -> pd.Series:
- daily = equity.resample("1D").last().ffill()
- index = pd.date_range(equity.index[0].normalize(), equity.index[-1].normalize(), freq="1D", tz="UTC")
- return daily.reindex(index).ffill()
- def metrics(series: pd.Series) -> dict[str, float]:
- years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
- total_return = float(series.iloc[-1] / series.iloc[0] - 1.0)
- annualized_return = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
- drawdown = float((series.cummax() - series).div(series.cummax()).max())
- return {
- "total_return": total_return,
- "annualized_return": annualized_return,
- "max_drawdown": drawdown,
- "calmar": annualized_return / drawdown if drawdown else 0.0,
- }
- def horizon_rows(name: str, series: pd.Series) -> list[dict[str, object]]:
- rows: list[dict[str, object]] = []
- end = series.index[-1]
- for label, offset in HORIZONS:
- horizon = series[series.index >= end - offset]
- rows.append(
- {
- "name": name,
- "horizon": label,
- "start": horizon.index[0].strftime("%Y-%m-%d"),
- "end": horizon.index[-1].strftime("%Y-%m-%d"),
- **metrics(horizon),
- }
- )
- return rows
- def monthly_rows(name: str, series: pd.Series) -> pd.DataFrame:
- monthly = series.resample("ME").last()
- frame = pd.DataFrame(
- {
- "name": name,
- "month": monthly.index.strftime("%Y-%m"),
- "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
- "end_equity": monthly.to_numpy(),
- }
- )
- frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
- return frame
- def worst_rolling_months(name: str, monthly: pd.DataFrame, windows: tuple[int, ...] = (6, 12)) -> list[dict[str, object]]:
- rows: list[dict[str, object]] = []
- returns = monthly.set_index("month")["return"]
- for window in windows:
- rolled = (1.0 + returns).rolling(window).apply(lambda values: float(values.prod()), raw=True) - 1.0
- worst_month = str(rolled.idxmin())
- rows.append(
- {
- "name": name,
- "window_months": window,
- "start_month": str(returns.index[returns.index.get_loc(worst_month) - window + 1]),
- "end_month": worst_month,
- "return": float(rolled.loc[worst_month]),
- }
- )
- return rows
- def rotation_risk_daily(years: float, row_index: int) -> tuple[str, pd.Series]:
- row = pd.read_csv(OUTPUT_DIR / "rotation-risk-top.csv").iloc[row_index]
- base = rotation_risk.params_from_row(row)
- params = rotation_risk.RiskParams(
- base=base,
- leverage=float(row["leverage"]),
- exposure=float(row["exposure"]),
- vol_target=float(row["vol_target"]),
- )
- frames = rotation_risk.rotation.load_symbol_bar_frames(years)
- closes = rotation_risk.rotation.aligned_closes(frames, base)
- signal_weights = rotation_risk.rotation.target_weights(closes, base)
- weights = rotation_risk.apply_risk_controls(closes, signal_weights, params)
- return params.name, daily_from_intraday(rotation_risk.equity_curve(closes, weights, params))
- def mean_reversion_eq_days30_daily(years: float) -> tuple[str, pd.Series]:
- eth_15m = load_base_candles("ETH-USDT-SWAP", years)
- btc_15m = load_base_candles("BTC-USDT-SWAP", years)
- eth_4h = resample_candles(eth_15m, "4H")
- btc_4h = resample_candles(btc_15m, "4H")
- eth_daily = resample_candles(eth_15m, "1D")
- btc_daily = resample_candles(btc_15m, "1D")
- candles, btc_candles = explore.align_pair_candles(eth_4h, btc_4h)
- params = mean_regime.BaseParams()
- baseline = mean_regime.run_segment(candles, btc_candles, eth_daily, btc_daily, params, mean_regime.Regime("baseline", "baseline", ""))
- baseline_frame = explore.cost_adjusted_trade_equity_frame(baseline, ROUNDTRIP_TAKER_COST_ON_MARGIN)
- baseline_start = pd.to_datetime(baseline.equity_curve[0]["ts"], unit="ms", utc=True)
- baseline_end = pd.to_datetime(baseline.equity_curve[-1]["ts"], unit="ms", utc=True)
- baseline_daily = daily_equity(baseline_frame, baseline_start, baseline_end)
- regime = mean_regime.Regime("eq_days30", "equity_momentum", "baseline only after previous 30 closed-trade net equity days are positive")
- allowed = mean_regime.baseline_equity_regime_series(regime, candles, baseline, baseline_daily)
- result = mean_regime.run_segment(candles, btc_candles, eth_daily, btc_daily, params, regime, allowed)
- frame = explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_TAKER_COST_ON_MARGIN)
- start = pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True)
- end = pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True)
- return regime.name, daily_equity(frame, start, end)
- def portfolio_series(name: str, returns: pd.DataFrame, weights: dict[str, float]) -> pd.Series:
- weighted = returns[list(weights)].mul(pd.Series(weights), axis=1).sum(axis=1)
- equity = INITIAL_EQUITY * (1.0 + weighted).cumprod()
- equity.name = name
- return equity
- def report_text(paths: list[Path], totals: pd.DataFrame, correlations: pd.DataFrame, horizons: pd.DataFrame, rolling: pd.DataFrame, monthly: pd.DataFrame) -> str:
- rotation = totals[totals["name"] == "rotation-risk"].iloc[0]
- portfolios = totals[totals["kind"] == "portfolio"].sort_values(["calmar", "total_return"], ascending=[False, False])
- best = portfolios.iloc[0]
- verdict = "优于单独 rotation-risk" if float(best["calmar"]) > float(rotation["calmar"]) and float(best["max_drawdown"]) <= float(rotation["max_drawdown"]) else "不优于单独 rotation-risk"
- lines = [
- "# Expansion portfolio evaluation",
- "",
- "Run command: `rtk .venv/bin/python scripts/evaluate_expansion_portfolio.py`",
- "",
- "Output files:",
- *[f"- `{path}`" for path in paths],
- "",
- "Candidates: rotation-risk top ranked row from `rotation-risk-top.csv`; mean-reversion-regime `eq_days30`.",
- "Portfolio construction: daily return blend on common dates, no extra fees beyond each strategy equity curve.",
- "",
- "## Verdict",
- "",
- f"{verdict}. Best blend by Calmar is `{best['name']}`.",
- "",
- "## Totals",
- "",
- markdown_table(totals),
- "",
- "## Correlation",
- "",
- markdown_table(correlations),
- "",
- "## Horizons",
- "",
- markdown_table(horizons),
- "",
- "## Worst rolling windows",
- "",
- markdown_table(rolling),
- "",
- "## Monthly returns",
- "",
- markdown_table(monthly.tail(180)),
- "",
- ]
- return "\n".join(lines)
- def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument("--years", type=float, default=10.0)
- parser.add_argument("--rotation-row", type=int, default=0)
- parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
- args = parser.parse_args()
- rotation_name, rotation_daily = rotation_risk_daily(args.years, args.rotation_row)
- mean_name, mean_daily = mean_reversion_eq_days30_daily(args.years)
- aligned = pd.DataFrame({"rotation-risk": rotation_daily, "mean-reversion-eq_days30": mean_daily}).dropna()
- returns = aligned.pct_change().fillna(0.0)
- series = {
- "rotation-risk": INITIAL_EQUITY * (1.0 + returns["rotation-risk"]).cumprod(),
- "mean-reversion-eq_days30": INITIAL_EQUITY * (1.0 + returns["mean-reversion-eq_days30"]).cumprod(),
- "portfolio-50-50": portfolio_series("portfolio-50-50", returns, {"rotation-risk": 0.50, "mean-reversion-eq_days30": 0.50}),
- "portfolio-70-30": portfolio_series("portfolio-70-30", returns, {"rotation-risk": 0.70, "mean-reversion-eq_days30": 0.30}),
- }
- total_rows = []
- horizon_output = []
- monthly_frames = []
- rolling_rows = []
- for name, equity in series.items():
- monthly = monthly_rows(name, equity)
- total_rows.append(
- {
- "name": name,
- "kind": "portfolio" if name.startswith("portfolio") else "single",
- "source_strategy": rotation_name if name == "rotation-risk" else mean_name if name == "mean-reversion-eq_days30" else "",
- "start": equity.index[0].strftime("%Y-%m-%d"),
- "end": equity.index[-1].strftime("%Y-%m-%d"),
- "years": (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365,
- **metrics(equity),
- }
- )
- horizon_output.extend(horizon_rows(name, equity))
- monthly_frames.append(monthly)
- rolling_rows.extend(worst_rolling_months(name, monthly))
- correlations = pd.DataFrame(
- [
- {
- "leg_a": "rotation-risk",
- "leg_b": "mean-reversion-eq_days30",
- "common_start": aligned.index[0].strftime("%Y-%m-%d"),
- "common_end": aligned.index[-1].strftime("%Y-%m-%d"),
- "daily_return_correlation": float(returns["rotation-risk"].corr(returns["mean-reversion-eq_days30"])),
- "equity_curve_correlation": float(aligned["rotation-risk"].corr(aligned["mean-reversion-eq_days30"])),
- }
- ]
- )
- totals = pd.DataFrame(total_rows)
- horizons = pd.DataFrame(horizon_output)
- monthly = pd.concat(monthly_frames, ignore_index=True)
- rolling = pd.DataFrame(rolling_rows)
- args.output_dir.mkdir(parents=True, exist_ok=True)
- totals_path = args.output_dir / "portfolio-totals.csv"
- correlations_path = args.output_dir / "portfolio-correlations.csv"
- horizons_path = args.output_dir / "portfolio-horizons.csv"
- monthly_path = args.output_dir / "portfolio-monthly-returns.csv"
- rolling_path = args.output_dir / "portfolio-rolling-worst.csv"
- report_path = args.output_dir / "portfolio-report.md"
- paths = [totals_path, correlations_path, horizons_path, monthly_path, rolling_path, report_path]
- totals.to_csv(totals_path, index=False)
- correlations.to_csv(correlations_path, index=False)
- horizons.to_csv(horizons_path, index=False)
- monthly.to_csv(monthly_path, index=False)
- rolling.to_csv(rolling_path, index=False)
- report_path.write_text(report_text(paths, totals, correlations, horizons, rolling, monthly), encoding="utf-8")
- print(totals.to_string(index=False))
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|