evaluate_expansion_portfolio.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from pathlib import Path
  5. import pandas as pd
  6. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  7. from scripts import explore_ultrashort as explore
  8. from scripts import refine_expansion_mean_reversion_regime as mean_regime
  9. from scripts import refine_expansion_rotation_risk as rotation_risk
  10. from scripts.search_eth_btc_nextgen_variants import markdown_table
  11. from scripts.search_expansion_mean_reversion import ROUNDTRIP_TAKER_COST_ON_MARGIN, daily_equity, load_base_candles, resample_candles
  12. OUTPUT_DIR = Path("reports/strategy-expansion")
  13. INITIAL_EQUITY = 10_000.0
  14. HORIZONS = (
  15. ("3y", pd.DateOffset(years=3)),
  16. ("1y", pd.DateOffset(years=1)),
  17. ("6m", pd.DateOffset(months=6)),
  18. ("3m", pd.DateOffset(months=3)),
  19. )
  20. def daily_from_intraday(equity: pd.Series) -> pd.Series:
  21. daily = equity.resample("1D").last().ffill()
  22. index = pd.date_range(equity.index[0].normalize(), equity.index[-1].normalize(), freq="1D", tz="UTC")
  23. return daily.reindex(index).ffill()
  24. def metrics(series: pd.Series) -> dict[str, float]:
  25. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  26. total_return = float(series.iloc[-1] / series.iloc[0] - 1.0)
  27. annualized_return = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
  28. drawdown = float((series.cummax() - series).div(series.cummax()).max())
  29. return {
  30. "total_return": total_return,
  31. "annualized_return": annualized_return,
  32. "max_drawdown": drawdown,
  33. "calmar": annualized_return / drawdown if drawdown else 0.0,
  34. }
  35. def horizon_rows(name: str, series: pd.Series) -> list[dict[str, object]]:
  36. rows: list[dict[str, object]] = []
  37. end = series.index[-1]
  38. for label, offset in HORIZONS:
  39. horizon = series[series.index >= end - offset]
  40. rows.append(
  41. {
  42. "name": name,
  43. "horizon": label,
  44. "start": horizon.index[0].strftime("%Y-%m-%d"),
  45. "end": horizon.index[-1].strftime("%Y-%m-%d"),
  46. **metrics(horizon),
  47. }
  48. )
  49. return rows
  50. def monthly_rows(name: str, series: pd.Series) -> pd.DataFrame:
  51. monthly = series.resample("ME").last()
  52. frame = pd.DataFrame(
  53. {
  54. "name": name,
  55. "month": monthly.index.strftime("%Y-%m"),
  56. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  57. "end_equity": monthly.to_numpy(),
  58. }
  59. )
  60. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  61. return frame
  62. def worst_rolling_months(name: str, monthly: pd.DataFrame, windows: tuple[int, ...] = (6, 12)) -> list[dict[str, object]]:
  63. rows: list[dict[str, object]] = []
  64. returns = monthly.set_index("month")["return"]
  65. for window in windows:
  66. rolled = (1.0 + returns).rolling(window).apply(lambda values: float(values.prod()), raw=True) - 1.0
  67. worst_month = str(rolled.idxmin())
  68. rows.append(
  69. {
  70. "name": name,
  71. "window_months": window,
  72. "start_month": str(returns.index[returns.index.get_loc(worst_month) - window + 1]),
  73. "end_month": worst_month,
  74. "return": float(rolled.loc[worst_month]),
  75. }
  76. )
  77. return rows
  78. def rotation_risk_daily(years: float, row_index: int) -> tuple[str, pd.Series]:
  79. row = pd.read_csv(OUTPUT_DIR / "rotation-risk-top.csv").iloc[row_index]
  80. base = rotation_risk.params_from_row(row)
  81. params = rotation_risk.RiskParams(
  82. base=base,
  83. leverage=float(row["leverage"]),
  84. exposure=float(row["exposure"]),
  85. vol_target=float(row["vol_target"]),
  86. )
  87. frames = rotation_risk.rotation.load_symbol_bar_frames(years)
  88. closes = rotation_risk.rotation.aligned_closes(frames, base)
  89. signal_weights = rotation_risk.rotation.target_weights(closes, base)
  90. weights = rotation_risk.apply_risk_controls(closes, signal_weights, params)
  91. return params.name, daily_from_intraday(rotation_risk.equity_curve(closes, weights, params))
  92. def mean_reversion_eq_days30_daily(years: float) -> tuple[str, pd.Series]:
  93. eth_15m = load_base_candles("ETH-USDT-SWAP", years)
  94. btc_15m = load_base_candles("BTC-USDT-SWAP", years)
  95. eth_4h = resample_candles(eth_15m, "4H")
  96. btc_4h = resample_candles(btc_15m, "4H")
  97. eth_daily = resample_candles(eth_15m, "1D")
  98. btc_daily = resample_candles(btc_15m, "1D")
  99. candles, btc_candles = explore.align_pair_candles(eth_4h, btc_4h)
  100. params = mean_regime.BaseParams()
  101. baseline = mean_regime.run_segment(candles, btc_candles, eth_daily, btc_daily, params, mean_regime.Regime("baseline", "baseline", ""))
  102. baseline_frame = explore.cost_adjusted_trade_equity_frame(baseline, ROUNDTRIP_TAKER_COST_ON_MARGIN)
  103. baseline_start = pd.to_datetime(baseline.equity_curve[0]["ts"], unit="ms", utc=True)
  104. baseline_end = pd.to_datetime(baseline.equity_curve[-1]["ts"], unit="ms", utc=True)
  105. baseline_daily = daily_equity(baseline_frame, baseline_start, baseline_end)
  106. regime = mean_regime.Regime("eq_days30", "equity_momentum", "baseline only after previous 30 closed-trade net equity days are positive")
  107. allowed = mean_regime.baseline_equity_regime_series(regime, candles, baseline, baseline_daily)
  108. result = mean_regime.run_segment(candles, btc_candles, eth_daily, btc_daily, params, regime, allowed)
  109. frame = explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_TAKER_COST_ON_MARGIN)
  110. start = pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True)
  111. end = pd.to_datetime(result.equity_curve[-1]["ts"], unit="ms", utc=True)
  112. return regime.name, daily_equity(frame, start, end)
  113. def portfolio_series(name: str, returns: pd.DataFrame, weights: dict[str, float]) -> pd.Series:
  114. weighted = returns[list(weights)].mul(pd.Series(weights), axis=1).sum(axis=1)
  115. equity = INITIAL_EQUITY * (1.0 + weighted).cumprod()
  116. equity.name = name
  117. return equity
  118. def report_text(paths: list[Path], totals: pd.DataFrame, correlations: pd.DataFrame, horizons: pd.DataFrame, rolling: pd.DataFrame, monthly: pd.DataFrame) -> str:
  119. rotation = totals[totals["name"] == "rotation-risk"].iloc[0]
  120. portfolios = totals[totals["kind"] == "portfolio"].sort_values(["calmar", "total_return"], ascending=[False, False])
  121. best = portfolios.iloc[0]
  122. verdict = "优于单独 rotation-risk" if float(best["calmar"]) > float(rotation["calmar"]) and float(best["max_drawdown"]) <= float(rotation["max_drawdown"]) else "不优于单独 rotation-risk"
  123. lines = [
  124. "# Expansion portfolio evaluation",
  125. "",
  126. "Run command: `rtk .venv/bin/python scripts/evaluate_expansion_portfolio.py`",
  127. "",
  128. "Output files:",
  129. *[f"- `{path}`" for path in paths],
  130. "",
  131. "Candidates: rotation-risk top ranked row from `rotation-risk-top.csv`; mean-reversion-regime `eq_days30`.",
  132. "Portfolio construction: daily return blend on common dates, no extra fees beyond each strategy equity curve.",
  133. "",
  134. "## Verdict",
  135. "",
  136. f"{verdict}. Best blend by Calmar is `{best['name']}`.",
  137. "",
  138. "## Totals",
  139. "",
  140. markdown_table(totals),
  141. "",
  142. "## Correlation",
  143. "",
  144. markdown_table(correlations),
  145. "",
  146. "## Horizons",
  147. "",
  148. markdown_table(horizons),
  149. "",
  150. "## Worst rolling windows",
  151. "",
  152. markdown_table(rolling),
  153. "",
  154. "## Monthly returns",
  155. "",
  156. markdown_table(monthly.tail(180)),
  157. "",
  158. ]
  159. return "\n".join(lines)
  160. def main() -> int:
  161. parser = argparse.ArgumentParser()
  162. parser.add_argument("--years", type=float, default=10.0)
  163. parser.add_argument("--rotation-row", type=int, default=0)
  164. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  165. args = parser.parse_args()
  166. rotation_name, rotation_daily = rotation_risk_daily(args.years, args.rotation_row)
  167. mean_name, mean_daily = mean_reversion_eq_days30_daily(args.years)
  168. aligned = pd.DataFrame({"rotation-risk": rotation_daily, "mean-reversion-eq_days30": mean_daily}).dropna()
  169. returns = aligned.pct_change().fillna(0.0)
  170. series = {
  171. "rotation-risk": INITIAL_EQUITY * (1.0 + returns["rotation-risk"]).cumprod(),
  172. "mean-reversion-eq_days30": INITIAL_EQUITY * (1.0 + returns["mean-reversion-eq_days30"]).cumprod(),
  173. "portfolio-50-50": portfolio_series("portfolio-50-50", returns, {"rotation-risk": 0.50, "mean-reversion-eq_days30": 0.50}),
  174. "portfolio-70-30": portfolio_series("portfolio-70-30", returns, {"rotation-risk": 0.70, "mean-reversion-eq_days30": 0.30}),
  175. }
  176. total_rows = []
  177. horizon_output = []
  178. monthly_frames = []
  179. rolling_rows = []
  180. for name, equity in series.items():
  181. monthly = monthly_rows(name, equity)
  182. total_rows.append(
  183. {
  184. "name": name,
  185. "kind": "portfolio" if name.startswith("portfolio") else "single",
  186. "source_strategy": rotation_name if name == "rotation-risk" else mean_name if name == "mean-reversion-eq_days30" else "",
  187. "start": equity.index[0].strftime("%Y-%m-%d"),
  188. "end": equity.index[-1].strftime("%Y-%m-%d"),
  189. "years": (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365,
  190. **metrics(equity),
  191. }
  192. )
  193. horizon_output.extend(horizon_rows(name, equity))
  194. monthly_frames.append(monthly)
  195. rolling_rows.extend(worst_rolling_months(name, monthly))
  196. correlations = pd.DataFrame(
  197. [
  198. {
  199. "leg_a": "rotation-risk",
  200. "leg_b": "mean-reversion-eq_days30",
  201. "common_start": aligned.index[0].strftime("%Y-%m-%d"),
  202. "common_end": aligned.index[-1].strftime("%Y-%m-%d"),
  203. "daily_return_correlation": float(returns["rotation-risk"].corr(returns["mean-reversion-eq_days30"])),
  204. "equity_curve_correlation": float(aligned["rotation-risk"].corr(aligned["mean-reversion-eq_days30"])),
  205. }
  206. ]
  207. )
  208. totals = pd.DataFrame(total_rows)
  209. horizons = pd.DataFrame(horizon_output)
  210. monthly = pd.concat(monthly_frames, ignore_index=True)
  211. rolling = pd.DataFrame(rolling_rows)
  212. args.output_dir.mkdir(parents=True, exist_ok=True)
  213. totals_path = args.output_dir / "portfolio-totals.csv"
  214. correlations_path = args.output_dir / "portfolio-correlations.csv"
  215. horizons_path = args.output_dir / "portfolio-horizons.csv"
  216. monthly_path = args.output_dir / "portfolio-monthly-returns.csv"
  217. rolling_path = args.output_dir / "portfolio-rolling-worst.csv"
  218. report_path = args.output_dir / "portfolio-report.md"
  219. paths = [totals_path, correlations_path, horizons_path, monthly_path, rolling_path, report_path]
  220. totals.to_csv(totals_path, index=False)
  221. correlations.to_csv(correlations_path, index=False)
  222. horizons.to_csv(horizons_path, index=False)
  223. monthly.to_csv(monthly_path, index=False)
  224. rolling.to_csv(rolling_path, index=False)
  225. report_path.write_text(report_text(paths, totals, correlations, horizons, rolling, monthly), encoding="utf-8")
  226. print(totals.to_string(index=False))
  227. return 0
  228. if __name__ == "__main__":
  229. raise SystemExit(main())