|
@@ -101,6 +101,20 @@ def daily_return_frame(equities: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
|
|
return combined.pct_change().dropna()
|
|
return combined.pct_change().dropna()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def apply_drawdown_overlay(frame: pd.DataFrame, threshold: float, reduced_exposure: float) -> pd.DataFrame:
|
|
|
|
|
+ raw = frame.sort_values("ts").reset_index(drop=True)
|
|
|
|
|
+ values = [float(raw["equity"].iloc[0])]
|
|
|
|
|
+ raw_peak = float(raw["equity"].iloc[0])
|
|
|
|
|
+ for index in range(1, len(raw)):
|
|
|
|
|
+ previous_raw_equity = float(raw["equity"].iloc[index - 1])
|
|
|
|
|
+ raw_peak = max(raw_peak, previous_raw_equity)
|
|
|
|
|
+ previous_drawdown = raw_peak / previous_raw_equity - 1.0 if previous_raw_equity else 0.0
|
|
|
|
|
+ exposure = reduced_exposure if previous_drawdown >= threshold else 1.0
|
|
|
|
|
+ raw_return = float(raw["equity"].iloc[index]) / previous_raw_equity - 1.0
|
|
|
|
|
+ values.append(values[-1] * (1.0 + raw_return * exposure))
|
|
|
|
|
+ return pd.DataFrame({"ts": raw["ts"], "equity": values})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def yearly_from_equity(label: str, frame: pd.DataFrame) -> list[dict[str, object]]:
|
|
def yearly_from_equity(label: str, frame: pd.DataFrame) -> list[dict[str, object]]:
|
|
|
year_end = frame.set_index("ts")["equity"].resample("YE").last().ffill()
|
|
year_end = frame.set_index("ts")["equity"].resample("YE").last().ffill()
|
|
|
year_start = year_end.shift(1)
|
|
year_start = year_end.shift(1)
|
|
@@ -315,6 +329,9 @@ def main() -> int:
|
|
|
portfolio_equities: dict[str, pd.DataFrame] = {}
|
|
portfolio_equities: dict[str, pd.DataFrame] = {}
|
|
|
portfolio_horizon_rows: list[dict[str, object]] = []
|
|
portfolio_horizon_rows: list[dict[str, object]] = []
|
|
|
portfolio_colors = ["#111827", "#b45309", "#0f766e"]
|
|
portfolio_colors = ["#111827", "#b45309", "#0f766e"]
|
|
|
|
|
+ overlay_rows: list[dict[str, object]] = []
|
|
|
|
|
+ overlay_horizon_rows: list[dict[str, object]] = []
|
|
|
|
|
+ overlay_curves: list[dict[str, object]] = []
|
|
|
for index, (portfolio_name, names) in enumerate(portfolio_specs):
|
|
for index, (portfolio_name, names) in enumerate(portfolio_specs):
|
|
|
portfolio_equity = combine_equities([recent_equities[name] for name in names])
|
|
portfolio_equity = combine_equities([recent_equities[name] for name in names])
|
|
|
metrics = explore.annualized_metrics_from_equity(
|
|
metrics = explore.annualized_metrics_from_equity(
|
|
@@ -362,6 +379,39 @@ def main() -> int:
|
|
|
"points": [(float(row.ts.timestamp()), float(row.equity)) for row in portfolio_equity.itertuples(index=False)],
|
|
"points": [(float(row.ts.timestamp()), float(row.equity)) for row in portfolio_equity.itertuples(index=False)],
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
|
|
+ if portfolio_name in {"Balanced 4", "Aggressive 5"}:
|
|
|
|
|
+ for threshold in (0.03, 0.05, 0.07):
|
|
|
|
|
+ for reduced_exposure in (0.0, 0.5):
|
|
|
|
|
+ overlay_name = f"{portfolio_name} DD>{threshold:.0%} x{reduced_exposure:.1f}"
|
|
|
|
|
+ overlay_equity = apply_drawdown_overlay(portfolio_equity, threshold, reduced_exposure)
|
|
|
|
|
+ overlay_metrics = explore.annualized_metrics_from_equity(
|
|
|
|
|
+ overlay_equity,
|
|
|
|
|
+ int(overlay_equity["ts"].iloc[0].timestamp() * 1000),
|
|
|
|
|
+ int(overlay_equity["ts"].iloc[-1].timestamp() * 1000),
|
|
|
|
|
+ )
|
|
|
|
|
+ overlay_rows.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "overlay": overlay_name,
|
|
|
|
|
+ "base": portfolio_name,
|
|
|
|
|
+ "threshold": pct(threshold),
|
|
|
|
|
+ "reduced_exposure": f"{reduced_exposure:.1f}",
|
|
|
|
|
+ "sort_calmar": overlay_metrics["net_calmar"],
|
|
|
|
|
+ "sort_annualized": overlay_metrics["net_annualized_return"],
|
|
|
|
|
+ "3y_return": pct(overlay_metrics["net_total_return"]),
|
|
|
|
|
+ "3y_annualized": pct(overlay_metrics["net_annualized_return"]),
|
|
|
|
|
+ "3y_max_dd": pct(overlay_metrics["net_max_drawdown"]),
|
|
|
|
|
+ "3y_calmar": f"{overlay_metrics['net_calmar']:.2f}",
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ overlay_horizon_rows.extend(horizon_rows(explore, overlay_name, "overlay", overlay_equity))
|
|
|
|
|
+ if portfolio_name == "Balanced 4" and threshold == 0.05:
|
|
|
|
|
+ overlay_curves.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "label": overlay_name,
|
|
|
|
|
+ "color": "#2563eb" if reduced_exposure == 0.5 else "#dc2626",
|
|
|
|
|
+ "points": [(float(row.ts.timestamp()), float(row.equity)) for row in overlay_equity.itertuples(index=False)],
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
summary = pd.DataFrame(result_rows)
|
|
summary = pd.DataFrame(result_rows)
|
|
|
monthly_frame = pd.DataFrame(monthly_rows)
|
|
monthly_frame = pd.DataFrame(monthly_rows)
|
|
@@ -370,6 +420,12 @@ def main() -> int:
|
|
|
portfolio_monthly = pd.DataFrame(portfolio_monthly_rows)
|
|
portfolio_monthly = pd.DataFrame(portfolio_monthly_rows)
|
|
|
strategy_horizon = pd.DataFrame(strategy_horizon_rows)
|
|
strategy_horizon = pd.DataFrame(strategy_horizon_rows)
|
|
|
portfolio_horizon = pd.DataFrame(portfolio_horizon_rows)
|
|
portfolio_horizon = pd.DataFrame(portfolio_horizon_rows)
|
|
|
|
|
+ overlay_summary = (
|
|
|
|
|
+ pd.DataFrame(overlay_rows)
|
|
|
|
|
+ .sort_values(["sort_calmar", "sort_annualized"], ascending=False)
|
|
|
|
|
+ .drop(columns=["sort_calmar", "sort_annualized"])
|
|
|
|
|
+ )
|
|
|
|
|
+ overlay_horizon = pd.DataFrame(overlay_horizon_rows)
|
|
|
portfolio_monthly_raw = pd.DataFrame(portfolio_monthly_raw_rows)
|
|
portfolio_monthly_raw = pd.DataFrame(portfolio_monthly_raw_rows)
|
|
|
correlations = daily_return_frame({**recent_equities, **portfolio_equities}).corr()
|
|
correlations = daily_return_frame({**recent_equities, **portfolio_equities}).corr()
|
|
|
correlation_rows = correlations.reset_index().rename(columns={"index": "name"})
|
|
correlation_rows = correlations.reset_index().rename(columns={"index": "name"})
|
|
@@ -387,6 +443,8 @@ def main() -> int:
|
|
|
portfolio_monthly.to_csv(REPORT_DIR / "ultrashort-portfolio-monthly.csv", index=False)
|
|
portfolio_monthly.to_csv(REPORT_DIR / "ultrashort-portfolio-monthly.csv", index=False)
|
|
|
strategy_horizon.to_csv(REPORT_DIR / "ultrashort-strategy-horizons.csv", index=False)
|
|
strategy_horizon.to_csv(REPORT_DIR / "ultrashort-strategy-horizons.csv", index=False)
|
|
|
portfolio_horizon.to_csv(REPORT_DIR / "ultrashort-portfolio-horizons.csv", index=False)
|
|
portfolio_horizon.to_csv(REPORT_DIR / "ultrashort-portfolio-horizons.csv", index=False)
|
|
|
|
|
+ overlay_summary.to_csv(REPORT_DIR / "ultrashort-overlay-summary.csv", index=False)
|
|
|
|
|
+ overlay_horizon.to_csv(REPORT_DIR / "ultrashort-overlay-horizons.csv", index=False)
|
|
|
correlation_rows.to_csv(REPORT_DIR / "ultrashort-correlation.csv", index=False)
|
|
correlation_rows.to_csv(REPORT_DIR / "ultrashort-correlation.csv", index=False)
|
|
|
worst_strategy_months.to_csv(REPORT_DIR / "ultrashort-worst-strategy-months.csv", index=False)
|
|
worst_strategy_months.to_csv(REPORT_DIR / "ultrashort-worst-strategy-months.csv", index=False)
|
|
|
worst_portfolio_months.to_csv(REPORT_DIR / "ultrashort-worst-portfolio-months.csv", index=False)
|
|
worst_portfolio_months.to_csv(REPORT_DIR / "ultrashort-worst-portfolio-months.csv", index=False)
|
|
@@ -426,6 +484,11 @@ def main() -> int:
|
|
|
<section class="panel">{render_table(portfolio_summary, ["portfolio", "legs", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
|
|
<section class="panel">{render_table(portfolio_summary, ["portfolio", "legs", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
|
|
|
<h2>组合分周期表现</h2>
|
|
<h2>组合分周期表现</h2>
|
|
|
<section class="panel">{render_table(portfolio_horizon, ["portfolio", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
|
|
<section class="panel">{render_table(portfolio_horizon, ["portfolio", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
|
|
|
|
|
+ <h2>回撤降仓 Overlay</h2>
|
|
|
|
|
+ <section class="panel">{make_svg([portfolio_curves[0], *overlay_curves])}</section>
|
|
|
|
|
+ <section class="panel">{render_table(overlay_summary, ["overlay", "base", "threshold", "reduced_exposure", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
|
|
|
|
|
+ <h2>Overlay 分周期表现</h2>
|
|
|
|
|
+ <section class="panel">{render_table(overlay_horizon, ["overlay", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
|
|
|
<h2>组合 2025-2026 月度收益</h2>
|
|
<h2>组合 2025-2026 月度收益</h2>
|
|
|
<section class="panel">{render_table(portfolio_monthly, ["portfolio", "period", "return", "end_equity"])}</section>
|
|
<section class="panel">{render_table(portfolio_monthly, ["portfolio", "period", "return", "end_equity"])}</section>
|
|
|
<h2>年度收益</h2>
|
|
<h2>年度收益</h2>
|