Files
quant/research/trend_rider_robustness.py
Gahow Wang 541f7bcf5b research: add strategy evaluation and exploration scripts
Add 28 research scripts covering DCA simulation, momentum evaluation,
Sharpe optimization, trend rider analysis, and US fundamentals exploration.
2026-05-14 12:54:08 +08:00

313 lines
10 KiB
Python

"""Robustness analysis for TrendRiderV3.
Run:
uv run python -m research.trend_rider_robustness
The module is import-safe for tests; price loading only happens in ``main``.
"""
from __future__ import annotations
import argparse
import os
from dataclasses import asdict, dataclass
from itertools import product
from typing import Iterable
import numpy as np
import pandas as pd
from strategies.permanent import (
ETF_UNIVERSE,
GLOBAL_ETF_UNIVERSE,
HK_ETF_UNIVERSE,
PermanentV4,
TREND_RIDER_V4_UNIVERSE,
TrendRiderV3,
TrendRiderV4,
)
@dataclass
class Evaluation:
name: str
start: str
end: str
days: int
cagr: float
volatility: float
sharpe: float
max_drawdown: float
calmar: float
final_multiple: float
switches: int
avg_daily_turnover: float
avg_gross_exposure: float
def portfolio_returns(
weights: pd.DataFrame,
prices: pd.DataFrame,
transaction_cost: float = 0.001,
) -> pd.Series:
aligned = weights.reindex(index=prices.index, columns=prices.columns).fillna(0.0)
returns = prices.pct_change(fill_method=None).fillna(0.0)
gross = (returns * aligned).sum(axis=1)
turnover = aligned.diff().abs().sum(axis=1).fillna(0.0)
return gross - turnover * transaction_cost
def evaluate_weights(
name: str,
weights: pd.DataFrame,
prices: pd.DataFrame,
transaction_cost: float = 0.001,
start: str | None = None,
end: str | None = None,
) -> Evaluation:
prices = prices.reindex(columns=weights.columns).dropna(how="all")
returns = portfolio_returns(weights, prices, transaction_cost=transaction_cost)
if start:
returns = returns[returns.index >= start]
weights = weights[weights.index >= start]
if end:
returns = returns[returns.index <= end]
weights = weights[weights.index <= end]
if returns.empty:
raise ValueError(f"No returns available for {name}")
equity = (1.0 + returns).cumprod()
span_years = max((returns.index[-1] - returns.index[0]).days / 365.25, 1 / 252)
cagr = float(equity.iloc[-1] ** (1 / span_years) - 1)
vol = float(returns.std(ddof=1) * np.sqrt(252)) if len(returns) > 1 else 0.0
sharpe = float(returns.mean() / returns.std(ddof=1) * np.sqrt(252)) if returns.std(ddof=1) > 0 else 0.0
drawdown = equity / equity.cummax() - 1.0
max_dd = float(drawdown.min())
turnover = weights.reindex(returns.index).fillna(0.0).diff().abs().sum(axis=1).fillna(0.0)
gross_exposure = weights.reindex(returns.index).fillna(0.0).abs().sum(axis=1)
return Evaluation(
name=name,
start=str(returns.index[0].date()),
end=str(returns.index[-1].date()),
days=int(len(returns)),
cagr=cagr,
volatility=vol,
sharpe=sharpe,
max_drawdown=max_dd,
calmar=float(cagr / abs(max_dd)) if max_dd < 0 else 0.0,
final_multiple=float(equity.iloc[-1]),
switches=int((turnover > 0.01).sum()),
avg_daily_turnover=float(turnover.mean()),
avg_gross_exposure=float(gross_exposure.mean()),
)
def evaluate_strategy(
name: str,
strategy: TrendRiderV3,
prices: pd.DataFrame,
transaction_cost: float = 0.001,
start: str | None = None,
end: str | None = None,
) -> tuple[Evaluation, pd.DataFrame]:
weights = strategy.generate_signals(prices)
result = evaluate_weights(
name,
weights,
prices[weights.columns],
transaction_cost=transaction_cost,
start=start,
end=end,
)
return result, weights
def default_parameter_grid() -> list[dict]:
return [
{
"vol_enter": vol_enter,
"dd_stop": dd_stop,
"peak_enter": peak_enter,
"mom_lookback": mom,
}
for vol_enter, dd_stop, peak_enter, mom in product(
[0.12, 0.14, 0.16],
[0.04, 0.05, 0.07],
[0.01, 0.02, 0.03],
[42, 63, 84],
)
]
def parameter_sweep(
prices: pd.DataFrame,
variants: Iterable[dict] | None = None,
transaction_cost: float = 0.001,
start: str | None = None,
end: str | None = None,
) -> pd.DataFrame:
rows = []
for kwargs in variants or default_parameter_grid():
strategy = TrendRiderV3(**kwargs)
result, _ = evaluate_strategy(
"param",
strategy,
prices,
transaction_cost=transaction_cost,
start=start,
end=end,
)
row = asdict(result)
row.update(kwargs)
rows.append(row)
return pd.DataFrame(rows).sort_values("cagr", ascending=False).reset_index(drop=True)
def annual_returns(returns: pd.Series) -> pd.Series:
return (1.0 + returns).groupby(returns.index.year).prod() - 1.0
def buy_hold_weights(prices: pd.DataFrame, symbol: str) -> pd.DataFrame:
weights = pd.DataFrame(0.0, index=prices.index, columns=[symbol])
if symbol in prices.columns:
first_valid = prices[symbol].first_valid_index()
if first_valid is not None:
weights.loc[weights.index >= first_valid, symbol] = 1.0
return weights
def candidate_weights(prices: pd.DataFrame) -> dict[str, pd.DataFrame]:
baseline = TrendRiderV3().generate_signals(prices)
diversified = TrendRiderV4().generate_signals(prices)
shy_defense = TrendRiderV3(risk_off=("GLD", "DBC", "SHY")).generate_signals(prices)
cash_defense = TrendRiderV3(risk_off=("SHY",)).generate_signals(prices)
permanent = PermanentV4().generate_signals(prices)
cols = sorted(set(baseline.columns) | set(permanent.columns))
base_aligned = baseline.reindex(columns=cols).fillna(0.0)
perm_aligned = permanent.reindex(index=baseline.index, columns=cols).fillna(0.0)
return {
"TrendRiderV3-US": baseline,
"TrendRiderV4": diversified,
"RiskOff+SHY": shy_defense,
"RiskOff=SHY": cash_defense,
"Blend75_TR25_PermanentV4": base_aligned * 0.75 + perm_aligned * 0.25,
"Blend50_TR50_PermanentV4": base_aligned * 0.50 + perm_aligned * 0.50,
"SPY Buy&Hold": buy_hold_weights(prices, "SPY"),
"QQQ Buy&Hold": buy_hold_weights(prices, "QQQ"),
}
def load_price_panel() -> pd.DataFrame:
from research.permanent_yearly import load_etfs
tickers = sorted(set(ETF_UNIVERSE + GLOBAL_ETF_UNIVERSE + HK_ETF_UNIVERSE + TREND_RIDER_V4_UNIVERSE))
etfs = load_etfs(tickers, start="2013-06-01")
nyse_index = etfs["SPY"].dropna().index
return etfs.reindex(nyse_index).ffill()
def _format_percent_frame(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
out = df.copy()
for col in cols:
out[col] = out[col].map(lambda x: f"{x * 100:,.2f}%")
return out
def main() -> None:
parser = argparse.ArgumentParser(description="TrendRiderV3 robustness report")
parser.add_argument("--start", default="2015-01-01")
parser.add_argument("--end", default=None)
parser.add_argument("--transaction-cost", type=float, default=0.001)
parser.add_argument("--out-dir", default="data")
args = parser.parse_args()
prices = load_price_panel()
if args.end:
prices = prices[prices.index <= args.end]
print(f"ETF panel: {prices.index.min().date()} to {prices.index.max().date()} | {prices.shape[1]} columns")
rows = []
weight_map = candidate_weights(prices)
for name, weights in weight_map.items():
rows.append(asdict(evaluate_weights(
name,
weights,
prices[weights.columns],
transaction_cost=args.transaction_cost,
start=args.start,
end=args.end,
)))
summary = pd.DataFrame(rows).sort_values(["max_drawdown", "cagr"], ascending=[False, False])
annual_map = {}
for name, weights in weight_map.items():
returns = portfolio_returns(
weights,
prices[weights.columns],
transaction_cost=args.transaction_cost,
)
returns = returns[returns.index >= args.start]
if args.end:
returns = returns[returns.index <= args.end]
annual_map[name] = annual_returns(returns)
years = pd.DataFrame(annual_map)
sweep = parameter_sweep(
prices,
transaction_cost=args.transaction_cost,
start=args.start,
end=args.end,
)
cost_rows = []
baseline_weights = weight_map["TrendRiderV3-US"]
for cost in [0.0, 0.001, 0.002, 0.005, 0.01]:
result = evaluate_weights(
f"cost_{cost:.3f}",
baseline_weights,
prices[baseline_weights.columns],
transaction_cost=cost,
start=args.start,
end=args.end,
)
row = asdict(result)
row["transaction_cost"] = cost
cost_rows.append(row)
costs = pd.DataFrame(cost_rows)
os.makedirs(args.out_dir, exist_ok=True)
summary_path = os.path.join(args.out_dir, "trend_rider_robustness_summary.csv")
years_path = os.path.join(args.out_dir, "trend_rider_robustness_years.csv")
sweep_path = os.path.join(args.out_dir, "trend_rider_robustness_params.csv")
costs_path = os.path.join(args.out_dir, "trend_rider_robustness_costs.csv")
summary.to_csv(summary_path, index=False)
years.to_csv(years_path)
sweep.to_csv(sweep_path, index=False)
costs.to_csv(costs_path, index=False)
metric_cols = ["cagr", "volatility", "sharpe", "max_drawdown", "calmar", "final_multiple", "switches"]
print("\nCandidate summary")
print(_format_percent_frame(summary[["name", *metric_cols]], ["cagr", "volatility", "max_drawdown"]).to_string(index=False))
print("\nAnnual returns")
annual_cols = [c for c in ["TrendRiderV3-US", "TrendRiderV4", "SPY Buy&Hold", "QQQ Buy&Hold"] if c in years.columns]
print(_format_percent_frame(years[annual_cols].reset_index(), annual_cols).to_string(index=False))
quant = sweep[["cagr", "max_drawdown", "sharpe", "final_multiple"]].quantile([0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0])
print("\nParameter-neighborhood quantiles")
print(_format_percent_frame(quant, ["cagr", "max_drawdown"]).to_string())
print("\nCost sensitivity")
print(_format_percent_frame(costs[["transaction_cost", "cagr", "max_drawdown", "final_multiple"]], ["transaction_cost", "cagr", "max_drawdown"]).to_string(index=False))
print(f"\nSaved: {summary_path}")
print(f"Saved: {years_path}")
print(f"Saved: {sweep_path}")
print(f"Saved: {costs_path}")
if __name__ == "__main__":
main()