initial import: etf strategy project
This commit is contained in:
164
scripts/auto_tune_etf_trend.py
Normal file
164
scripts/auto_tune_etf_trend.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from qfr.strategy.etf_trend import Constraints, TrendParams, UniverseAsset, run_backtest
|
||||
|
||||
|
||||
def load_universe(config_path: Path) -> tuple[list[UniverseAsset], Constraints, str, str]:
|
||||
conf = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
universe = [UniverseAsset(**a) for a in conf["assets"]]
|
||||
|
||||
cons = conf.get("constraints", {})
|
||||
constraints = Constraints(
|
||||
max_positions=int(cons.get("max_positions", 4)),
|
||||
must_commodity=int(cons.get("must_include", {}).get("commodity", 0)),
|
||||
must_rates=int(cons.get("must_include", {}).get("rates", 0)),
|
||||
must_equity=int(cons.get("must_include", {}).get("equity", 0)),
|
||||
)
|
||||
|
||||
risk_proxy = cons.get("risk_proxy", "510300.SH")
|
||||
rates_fallback = cons.get("rates_fallback", "511010.SH")
|
||||
|
||||
return universe, constraints, risk_proxy, rates_fallback
|
||||
|
||||
|
||||
def load_prices(raw_dir: Path, universe: list[UniverseAsset], start: str, end: str) -> dict[str, pd.DataFrame]:
|
||||
out: dict[str, pd.DataFrame] = {}
|
||||
for a in universe:
|
||||
fn = raw_dir / f"{a.ts_code.replace('.', '')}.parquet"
|
||||
df = pd.read_parquet(fn)
|
||||
df = df.copy()
|
||||
df["trade_date"] = df["trade_date"].astype(str)
|
||||
df = df[(df["trade_date"] >= start) & (df["trade_date"] <= end)]
|
||||
out[a.ts_code] = df
|
||||
return out
|
||||
|
||||
|
||||
def perf_stats(equity: pd.Series) -> dict[str, float]:
|
||||
r = equity.pct_change().dropna()
|
||||
if r.empty:
|
||||
return {}
|
||||
ann_ret = float((equity.iloc[-1] / equity.iloc[0]) ** (252 / len(r)) - 1)
|
||||
ann_vol = float(r.std(ddof=1) * (252 ** 0.5))
|
||||
dd = float((equity / equity.cummax() - 1.0).min())
|
||||
calmar = float(ann_ret / abs(dd)) if dd < 0 else float("nan")
|
||||
return {"ann_return": ann_ret, "ann_vol": ann_vol, "max_drawdown": dd, "calmar": calmar}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--config", default="configs/etf_universe.json")
|
||||
p.add_argument("--rawdir", default="data/raw")
|
||||
p.add_argument("--start", default="20200101")
|
||||
p.add_argument("--end", default="20251231")
|
||||
p.add_argument("--out", default="data/tune_results.parquet")
|
||||
args = p.parse_args()
|
||||
|
||||
config_path = Path(args.config)
|
||||
raw_dir = Path(args.rawdir)
|
||||
|
||||
universe, constraints, risk_proxy, rates_fallback = load_universe(config_path)
|
||||
prices = load_prices(raw_dir, universe, args.start, args.end)
|
||||
|
||||
base = TrendParams()
|
||||
|
||||
# small grid to keep runtime reasonable
|
||||
fast_list = [5, 10]
|
||||
slow_list = [20, 40]
|
||||
atr_mult_list = [2.5, 3.0]
|
||||
vol_window_list = [10, 20]
|
||||
port_vol_window_list = [40, 60]
|
||||
max_positions_list = [3, 4]
|
||||
|
||||
rows = []
|
||||
|
||||
for sma_fast, sma_slow, atr_mult, vol_window, port_vol_window, max_positions in itertools.product(
|
||||
fast_list,
|
||||
slow_list,
|
||||
atr_mult_list,
|
||||
vol_window_list,
|
||||
port_vol_window_list,
|
||||
max_positions_list,
|
||||
):
|
||||
if sma_fast >= sma_slow:
|
||||
continue
|
||||
|
||||
params = replace(
|
||||
base,
|
||||
sma_fast=sma_fast,
|
||||
sma_slow=sma_slow,
|
||||
atr_mult=atr_mult,
|
||||
vol_window=vol_window,
|
||||
port_vol_window=port_vol_window,
|
||||
max_positions=max_positions,
|
||||
rebalance_every=1,
|
||||
)
|
||||
|
||||
cons = replace(constraints, max_positions=max_positions)
|
||||
|
||||
equity, _weights = run_backtest(
|
||||
prices,
|
||||
universe,
|
||||
cons,
|
||||
params,
|
||||
rates_fallback=rates_fallback,
|
||||
risk_proxy=risk_proxy,
|
||||
)
|
||||
|
||||
st = perf_stats(equity["equity"])
|
||||
if not st:
|
||||
continue
|
||||
|
||||
row = {
|
||||
"sma_fast": sma_fast,
|
||||
"sma_slow": sma_slow,
|
||||
"atr_mult": atr_mult,
|
||||
"vol_window": vol_window,
|
||||
"port_vol_window": port_vol_window,
|
||||
"max_positions": max_positions,
|
||||
**st,
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
if df.empty:
|
||||
print("no results")
|
||||
return
|
||||
|
||||
# filter by vol constraint first, then sort by ann_return
|
||||
filt = df[df["ann_vol"] <= 0.18].copy()
|
||||
if filt.empty:
|
||||
filt = df.copy()
|
||||
|
||||
filt = filt.sort_values(["ann_return", "calmar"], ascending=False)
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
filt.to_parquet(out, index=False)
|
||||
|
||||
print("top10")
|
||||
cols = [
|
||||
"ann_return",
|
||||
"ann_vol",
|
||||
"max_drawdown",
|
||||
"calmar",
|
||||
"sma_fast",
|
||||
"sma_slow",
|
||||
"atr_mult",
|
||||
"vol_window",
|
||||
"port_vol_window",
|
||||
"max_positions",
|
||||
]
|
||||
print(filt[cols].head(10).to_string(index=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user