validate_calendar_carry_fusion_leg.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from pathlib import Path
  5. import pandas as pd
  6. ROOT = Path(__file__).resolve().parents[1]
  7. sys.path.insert(0, str(ROOT))
  8. from scripts import search_eth_btc_calendar_carry as carry
  9. from scripts import search_long_short_fusion as fusion
  10. from scripts.search_short_bias_overlay import markdown_table
  11. OUT_DIR = Path("reports/long-short-fusion")
  12. PREFIX = "calendar-carry-fusion-leg"
  13. CANDIDATE = carry.Spec("ETH-USDT-SWAP", "1h", "long", 14, "weekend", 8, "calm")
  14. SELECTED_PATH = OUT_DIR / "fusion-selected.csv"
  15. WEIGHTS = (0.00, 0.025, 0.05, 0.075, 0.10, 0.125)
  16. HORIZONS = (
  17. ("full", None),
  18. ("3y", pd.DateOffset(years=3)),
  19. ("1y", pd.DateOffset(years=1)),
  20. ("6m", pd.DateOffset(months=6)),
  21. ("3m", pd.DateOffset(months=3)),
  22. )
  23. def selected_baseline() -> pd.Series:
  24. selected = pd.read_csv(SELECTED_PATH)
  25. return selected.iloc[0]
  26. def baseline_equity(row: pd.Series, years: float) -> pd.Series:
  27. components = fusion.build_components(years)
  28. series = {key: value[1] for key, value in components.items()}
  29. weights = {
  30. str(row["long_variant"]): float(row["long_weight"]),
  31. "btc_risk_short": float(row["btc_risk_short_weight"]),
  32. "eth_4h_vol_short": float(row["eth_4h_vol_short_weight"]),
  33. "btc_4h_vol_short": float(row["btc_4h_vol_short_weight"]),
  34. "eth_4h_vol_short_gated": float(row["eth_4h_vol_short_gated_weight"]),
  35. "btc_4h_vol_short_gated": float(row["btc_4h_vol_short_gated_weight"]),
  36. }
  37. return fusion.combine_components(series, weights)
  38. def candidate_equity() -> pd.Series:
  39. frame = carry.resample(carry.load_frame("ETH-USDT-SWAP"), "1h")
  40. equity, _ = carry.run_spec(CANDIDATE, frame)
  41. return equity
  42. def metrics(series: pd.Series) -> dict[str, float]:
  43. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  44. years = (series.index[-1] - series.index[0]).total_seconds() / 31_536_000
  45. annual = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else -1.0
  46. drawdown = float(((series.cummax() - series) / series.cummax()).max())
  47. return {
  48. "total_return": total,
  49. "annualized_return": annual,
  50. "max_drawdown": drawdown,
  51. "calmar": annual / drawdown if drawdown else 0.0,
  52. }
  53. def horizon_slice(series: pd.Series, offset: pd.DateOffset | None) -> pd.Series:
  54. if offset is None:
  55. return series
  56. scoped = series[series.index >= series.index[-1] - offset]
  57. return scoped if len(scoped) >= 2 else series
  58. def monthly_returns(series: pd.Series) -> pd.Series:
  59. monthly = series.resample("ME").last()
  60. return monthly / monthly.shift(1).fillna(series.iloc[0]) - 1.0
  61. def yearly_returns(series: pd.Series) -> pd.Series:
  62. monthly = monthly_returns(series)
  63. return monthly.groupby(monthly.index.year).apply(lambda values: float((1.0 + values).prod() - 1.0))
  64. def build_portfolios(base: pd.Series, candidate: pd.Series) -> dict[float, pd.Series]:
  65. aligned = pd.DataFrame({"base": base, "candidate": candidate}).dropna()
  66. base_returns = aligned["base"].pct_change().fillna(0.0)
  67. candidate_returns = aligned["candidate"].pct_change().fillna(0.0)
  68. portfolios: dict[float, pd.Series] = {}
  69. for weight in WEIGHTS:
  70. returns = base_returns + weight * candidate_returns
  71. portfolios[weight] = fusion.INITIAL_EQUITY * (1.0 + returns).cumprod()
  72. return portfolios
  73. def horizon_rows(portfolios: dict[float, pd.Series]) -> pd.DataFrame:
  74. baseline = portfolios[0.0]
  75. rows: list[dict[str, object]] = []
  76. baseline_metrics = {label: metrics(horizon_slice(baseline, offset)) for label, offset in HORIZONS}
  77. for weight, equity in portfolios.items():
  78. for label, offset in HORIZONS:
  79. scoped = horizon_slice(equity, offset)
  80. values = metrics(scoped)
  81. base_values = baseline_metrics[label]
  82. rows.append(
  83. {
  84. "calendar_weight": weight,
  85. "horizon": label,
  86. "start": scoped.index[0].strftime("%Y-%m-%d"),
  87. "end": scoped.index[-1].strftime("%Y-%m-%d"),
  88. **values,
  89. "delta_total": values["total_return"] - base_values["total_return"],
  90. "delta_annualized": values["annualized_return"] - base_values["annualized_return"],
  91. "delta_max_drawdown": values["max_drawdown"] - base_values["max_drawdown"],
  92. "delta_calmar": values["calmar"] - base_values["calmar"],
  93. }
  94. )
  95. return pd.DataFrame(rows)
  96. def deterioration_rows(portfolios: dict[float, pd.Series]) -> pd.DataFrame:
  97. baseline_monthly = monthly_returns(portfolios[0.0])
  98. baseline_yearly = yearly_returns(portfolios[0.0])
  99. rows: list[dict[str, object]] = []
  100. for weight, equity in portfolios.items():
  101. monthly = monthly_returns(equity)
  102. yearly = yearly_returns(equity)
  103. month_delta = (monthly - baseline_monthly).dropna()
  104. year_delta = (yearly - baseline_yearly).dropna()
  105. rows.append(
  106. {
  107. "calendar_weight": weight,
  108. "worse_years": int((year_delta < 0.0).sum()),
  109. "total_years": int(len(year_delta)),
  110. "worst_year_delta": float(year_delta.min()) if len(year_delta) else 0.0,
  111. "worse_months": int((month_delta < 0.0).sum()),
  112. "total_months": int(len(month_delta)),
  113. "worst_month_delta": float(month_delta.min()) if len(month_delta) else 0.0,
  114. }
  115. )
  116. return pd.DataFrame(rows)
  117. def detail_rows(portfolios: dict[float, pd.Series], period: str) -> pd.DataFrame:
  118. baseline = monthly_returns(portfolios[0.0]) if period == "month" else yearly_returns(portfolios[0.0])
  119. rows: list[dict[str, object]] = []
  120. for weight, equity in portfolios.items():
  121. values = monthly_returns(equity) if period == "month" else yearly_returns(equity)
  122. delta = (values - baseline).dropna()
  123. for index, value in values.dropna().items():
  124. rows.append(
  125. {
  126. "calendar_weight": weight,
  127. period: index.strftime("%Y-%m") if period == "month" else int(index),
  128. "return": float(value),
  129. "baseline_return": float(baseline.loc[index]),
  130. "delta": float(delta.loc[index]),
  131. "worse_than_baseline": bool(delta.loc[index] < 0.0),
  132. }
  133. )
  134. return pd.DataFrame(rows)
  135. def verdict(horizons: pd.DataFrame, deterioration: pd.DataFrame) -> tuple[str, float]:
  136. full = horizons[horizons["horizon"] == "full"].copy()
  137. positive = full[
  138. (full["calendar_weight"] > 0.0)
  139. & (full["delta_total"] > 0.0)
  140. & (full["delta_calmar"] > 0.0)
  141. & (full["delta_max_drawdown"] <= 0.0)
  142. ]
  143. stable = deterioration[(deterioration["calendar_weight"] > 0.0) & (deterioration["worse_years"] <= 2)]
  144. eligible = positive[positive["calendar_weight"].isin(set(stable["calendar_weight"]))]
  145. if len(eligible) == 0:
  146. return "do not include in the next main portfolio search", 0.0
  147. max_weight = float(eligible["calendar_weight"].max())
  148. return "include as a low-weight leg in the next main portfolio search", max_weight
  149. def report(
  150. command: str,
  151. baseline: pd.Series,
  152. horizons: pd.DataFrame,
  153. deterioration: pd.DataFrame,
  154. years: pd.DataFrame,
  155. months: pd.DataFrame,
  156. ) -> str:
  157. decision, max_weight = verdict(horizons, deterioration)
  158. summary = horizons[horizons["horizon"].isin(["full", "3y", "1y", "6m", "3m"])]
  159. year_check = years[years["calendar_weight"] > 0.0]
  160. month_check = months[months["calendar_weight"] > 0.0].sort_values("delta").head(20)
  161. return "\n".join(
  162. [
  163. "# Calendar Carry Fusion Leg Validation",
  164. "",
  165. f"Run command: `{command}`",
  166. "",
  167. "Scope: research-only validation under `reports/long-short-fusion`; no live path changed.",
  168. f"Baseline: first row of `{SELECTED_PATH}` rebuilt from long-short fusion research components.",
  169. f"Baseline date intersection: {baseline.index[0].strftime('%Y-%m-%d')} to {baseline.index[-1].strftime('%Y-%m-%d')}.",
  170. f"Candidate leg: `{CANDIDATE.name}`.",
  171. "",
  172. f"Decision: **{decision}**.",
  173. f"Suggested calendar weight upper bound: **{max_weight:.3f}**.",
  174. "",
  175. "## Horizon Weight Search",
  176. "",
  177. markdown_table(summary),
  178. "",
  179. "## Deterioration Summary",
  180. "",
  181. markdown_table(deterioration),
  182. "",
  183. "## Year Check",
  184. "",
  185. markdown_table(year_check),
  186. "",
  187. "## Worst Monthly Deltas",
  188. "",
  189. markdown_table(month_check),
  190. "",
  191. ]
  192. )
  193. def main() -> int:
  194. parser = argparse.ArgumentParser()
  195. parser.add_argument("--years", type=float, default=8.0)
  196. parser.add_argument("--output-dir", type=Path, default=OUT_DIR)
  197. args = parser.parse_args()
  198. selected = selected_baseline()
  199. base = baseline_equity(selected, args.years)
  200. candidate = candidate_equity()
  201. portfolios = build_portfolios(base, candidate)
  202. baseline = portfolios[0.0]
  203. horizons = horizon_rows(portfolios)
  204. deterioration = deterioration_rows(portfolios)
  205. years = detail_rows(portfolios, "year")
  206. months = detail_rows(portfolios, "month")
  207. args.output_dir.mkdir(parents=True, exist_ok=True)
  208. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  209. deterioration_path = args.output_dir / f"{PREFIX}-deterioration.csv"
  210. years_path = args.output_dir / f"{PREFIX}-years.csv"
  211. months_path = args.output_dir / f"{PREFIX}-months.csv"
  212. report_path = args.output_dir / f"{PREFIX}.md"
  213. horizons.to_csv(horizon_path, index=False)
  214. deterioration.to_csv(deterioration_path, index=False)
  215. years.to_csv(years_path, index=False)
  216. months.to_csv(months_path, index=False)
  217. report_path.write_text(
  218. report(
  219. f"rtk .venv/bin/python scripts/validate_calendar_carry_fusion_leg.py --years {args.years}",
  220. baseline,
  221. horizons,
  222. deterioration,
  223. years,
  224. months,
  225. ),
  226. encoding="utf-8",
  227. )
  228. print(report_path)
  229. print(horizons.to_string(index=False))
  230. print(deterioration.to_string(index=False))
  231. return 0
  232. if __name__ == "__main__":
  233. raise SystemExit(main())