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.
This commit is contained in:
312
research/trend_rider_robustness.py
Normal file
312
research/trend_rider_robustness.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user