rename directory
This commit is contained in:
455
backtest paper trading/grid_search.py
Normal file
455
backtest paper trading/grid_search.py
Normal file
@@ -0,0 +1,455 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
grid_search.py
|
||||
==============
|
||||
Motore di grid search con walk-forward validation per il sistema kNN.
|
||||
|
||||
Componenti chiave:
|
||||
- ParameterGrid → genera la lista di combinazioni di parametri
|
||||
- TimeSeriesSplitter → split walk-forward train/test con embargo
|
||||
- run_grid_search → orchestratore: per ogni ISIN e ogni fold, esegue il
|
||||
backtest multi-day, aggrega le metriche
|
||||
- aggregate_results → per ogni cella della grid, calcola Sharpe medio,
|
||||
Stability (1/std dello Sharpe fra fold), e l'IS-OOS
|
||||
gap (overfit detector)
|
||||
|
||||
Convenzioni:
|
||||
- TUTTE le metriche sono calcolate sul fold di TEST (out-of-sample), MAI in-sample
|
||||
- L'embargo evita overlap pattern-library fra train e test (purged k-fold variant)
|
||||
- I parametri "vincenti" sono quelli con Sharpe-mean alto E Stability alta E IS-OOS
|
||||
gap basso (ovvero non hanno overfittato il train)
|
||||
|
||||
L'output è pronto per essere consumato da report.py per la visualizzazione.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from knn_backtest_multiday import knn_forward_backtest_multiday
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Parameter Grid
|
||||
# =============================================================
|
||||
@dataclass
|
||||
class ParameterGrid:
|
||||
"""
|
||||
Definisce le combinazioni di parametri da testare.
|
||||
|
||||
Esempio:
|
||||
grid = ParameterGrid(
|
||||
Wp=[40, 60, 80],
|
||||
Ha=[5, 10, 15],
|
||||
k=[15, 25, 35],
|
||||
theta_entry=[0.0, 0.005, 0.01],
|
||||
sl_bps=[200, 300, 500],
|
||||
tp_bps=[600, 800, 1200],
|
||||
trail_bps=[200, 300],
|
||||
time_stop_bars=[10, 20],
|
||||
decision_every=[1, 3, 5, 10],
|
||||
min_holding_bars=[0, 3],
|
||||
)
|
||||
list(grid.iterate()) # → lista di dict con le combinazioni
|
||||
"""
|
||||
Wp: Sequence[int] = (60,)
|
||||
Ha: Sequence[int] = (10,)
|
||||
k: Sequence[int] = (25,)
|
||||
theta_entry: Sequence[float] = (0.005,)
|
||||
sl_bps: Sequence[Optional[float]] = (300.0,)
|
||||
tp_bps: Sequence[Optional[float]] = (800.0,)
|
||||
trail_bps: Sequence[Optional[float]] = (300.0,)
|
||||
time_stop_bars: Sequence[Optional[int]] = (20,)
|
||||
theta_exit: Sequence[Optional[float]] = (0.0,)
|
||||
weak_days_exit: Sequence[Optional[int]] = (None,)
|
||||
decision_every: Sequence[int] = (1,)
|
||||
min_holding_bars: Sequence[int] = (0,)
|
||||
only_first_signal: Sequence[bool] = (False,)
|
||||
fee_bps: Sequence[float] = (10.0,)
|
||||
|
||||
def iterate(self):
|
||||
"""Yield ogni combinazione come dict."""
|
||||
fields_order = [
|
||||
"Wp", "Ha", "k", "theta_entry",
|
||||
"sl_bps", "tp_bps", "trail_bps", "time_stop_bars",
|
||||
"theta_exit", "weak_days_exit",
|
||||
"decision_every", "min_holding_bars", "only_first_signal",
|
||||
"fee_bps",
|
||||
]
|
||||
values_lists = [getattr(self, f) for f in fields_order]
|
||||
for combo in itertools.product(*values_lists):
|
||||
yield dict(zip(fields_order, combo))
|
||||
|
||||
def size(self) -> int:
|
||||
return int(np.prod([len(getattr(self, f)) for f in [
|
||||
"Wp", "Ha", "k", "theta_entry",
|
||||
"sl_bps", "tp_bps", "trail_bps", "time_stop_bars",
|
||||
"theta_exit", "weak_days_exit",
|
||||
"decision_every", "min_holding_bars", "only_first_signal",
|
||||
"fee_bps",
|
||||
]]))
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Walk-forward splitter
|
||||
# =============================================================
|
||||
@dataclass
|
||||
class TimeSeriesSplitter:
|
||||
"""
|
||||
Walk-forward splitter con embargo (purged k-fold per time series).
|
||||
|
||||
Esempio con n_splits=4, train_size=500, test_size=125, embargo=20:
|
||||
|
||||
[---- train ----][emb][--- test ---] fold 1
|
||||
[---- train ----][emb][--- test ---] fold 2
|
||||
[---- train ----][emb][--- test ---] fold 3
|
||||
[---- train ----][emb][--- test ---] fold 4
|
||||
|
||||
Restituisce tuple (train_start, train_end, test_start, test_end) come
|
||||
INDICI POSIZIONALI nella serie temporale.
|
||||
"""
|
||||
n_splits: int = 4
|
||||
train_size: int = 504 # ~2 anni di business days
|
||||
test_size: int = 126 # ~6 mesi
|
||||
embargo: int = 20 # gap fra train e test per evitare leakage
|
||||
|
||||
def split(self, n_obs: int):
|
||||
"""
|
||||
Yield (i_train_start, i_train_end, i_test_start, i_test_end).
|
||||
Gli indici sono inclusivi a sinistra ed esclusivi a destra (Python style).
|
||||
"""
|
||||
min_total = self.train_size + self.embargo + self.test_size
|
||||
if n_obs < min_total:
|
||||
raise ValueError(
|
||||
f"Serie troppo corta ({n_obs} barre) per n_splits={self.n_splits}, "
|
||||
f"train_size={self.train_size}, embargo={self.embargo}, "
|
||||
f"test_size={self.test_size}. Servono almeno {min_total} barre."
|
||||
)
|
||||
|
||||
# Distribuisci gli start dei test su tutto il range disponibile
|
||||
first_test_start = self.train_size + self.embargo
|
||||
last_test_end = n_obs
|
||||
if self.n_splits == 1:
|
||||
test_starts = [first_test_start]
|
||||
else:
|
||||
available_test_span = last_test_end - first_test_start - self.test_size
|
||||
if available_test_span <= 0:
|
||||
# Non c'è spazio per più di un fold
|
||||
test_starts = [first_test_start]
|
||||
else:
|
||||
stride = available_test_span / max(1, self.n_splits - 1)
|
||||
test_starts = [int(round(first_test_start + i * stride)) for i in range(self.n_splits)]
|
||||
|
||||
for ts in test_starts:
|
||||
te = min(ts + self.test_size, n_obs)
|
||||
tr_end = ts - self.embargo
|
||||
tr_start = max(0, tr_end - self.train_size)
|
||||
if te - ts < self.test_size // 2:
|
||||
continue # fold troppo corto, skip
|
||||
yield tr_start, tr_end, ts, te
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Singolo backtest su un singolo ISIN per un singolo set di parametri
|
||||
# =============================================================
|
||||
def _run_one_combo_on_asset(
|
||||
df_asset: pd.DataFrame,
|
||||
col_date: str,
|
||||
col_ret: str,
|
||||
params: dict,
|
||||
exec_ret: Optional[pd.Series] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Esegue knn_forward_backtest_multiday con i parametri specificati e
|
||||
restituisce le metriche di sintesi (stats dict).
|
||||
|
||||
Se il backtest fallisce, restituisce un dict con NaN per garantire che
|
||||
la grid search non si interrompa.
|
||||
"""
|
||||
try:
|
||||
sig_df, stats = knn_forward_backtest_multiday(
|
||||
df_isin=df_asset,
|
||||
col_date=col_date,
|
||||
col_ret=col_ret,
|
||||
Wp=params["Wp"],
|
||||
Ha=params["Ha"],
|
||||
k=params["k"],
|
||||
theta_entry=params["theta_entry"],
|
||||
exec_ret=exec_ret,
|
||||
fee_bps=params["fee_bps"],
|
||||
sl_bps=params["sl_bps"],
|
||||
tp_bps=params["tp_bps"],
|
||||
trail_bps=params["trail_bps"],
|
||||
time_stop_bars=params["time_stop_bars"],
|
||||
theta_exit=params["theta_exit"],
|
||||
weak_days_exit=params["weak_days_exit"],
|
||||
decision_every=params["decision_every"],
|
||||
min_holding_bars=params["min_holding_bars"],
|
||||
only_first_signal=params["only_first_signal"],
|
||||
)
|
||||
return stats
|
||||
except Exception as exc:
|
||||
return {
|
||||
"Sharpe": np.nan, "CAGR_%": np.nan, "MaxDD_%eq": np.nan,
|
||||
"Calmar": np.nan, "Sortino": np.nan, "N_Trades": 0,
|
||||
"HitRate_%": np.nan, "Turnover_%/step": np.nan,
|
||||
"AvgTradeRet_bps": np.nan, "AnnVol_%": np.nan,
|
||||
"_error": str(exc),
|
||||
**params,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Main grid search loop con walk-forward
|
||||
# =============================================================
|
||||
def run_grid_search(
|
||||
assets: dict[str, pd.DataFrame],
|
||||
col_date: str,
|
||||
col_ret: str,
|
||||
grid: ParameterGrid,
|
||||
splitter: TimeSeriesSplitter,
|
||||
exec_ret_map: Optional[dict[str, pd.Series]] = None,
|
||||
*,
|
||||
verbose: bool = True,
|
||||
n_max_combos: Optional[int] = None,
|
||||
save_intermediate_to: Optional[Path] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Esegue grid search walk-forward su una collezione di ISIN.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
assets : dict {isin -> df_asset}
|
||||
Mappa ISIN → DataFrame con almeno le colonne col_date e col_ret.
|
||||
grid : ParameterGrid
|
||||
splitter : TimeSeriesSplitter
|
||||
exec_ret_map : dict {isin -> pd.Series}, opzionale
|
||||
Rendimenti open-to-open per esecuzione realistica.
|
||||
verbose : bool
|
||||
Stampa progresso.
|
||||
n_max_combos : int, opzionale
|
||||
Se specificato, esegue solo le prime N combinazioni (utile per test).
|
||||
save_intermediate_to : Path, opzionale
|
||||
Se specificato, salva il DataFrame parziale ogni 10 combinazioni
|
||||
in un file XLSX per ripartire in caso di crash.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DataFrame "lungo": una riga per (ISIN, combo_id, fold_id)
|
||||
Colonne: tutte le metriche + parametri + identificativo ISIN / fold.
|
||||
"""
|
||||
total_combos = grid.size()
|
||||
if n_max_combos:
|
||||
total_combos = min(total_combos, n_max_combos)
|
||||
|
||||
if verbose:
|
||||
print(f"[GRID] Combinazioni da testare: {total_combos}")
|
||||
print(f"[GRID] ISIN: {len(assets)}")
|
||||
print(f"[GRID] Fold per ISIN: {splitter.n_splits}")
|
||||
print(f"[GRID] Backtest totali stimati: {total_combos * len(assets) * splitter.n_splits:,}")
|
||||
|
||||
rows = []
|
||||
combo_id = 0
|
||||
t_start = time.perf_counter()
|
||||
|
||||
for params in grid.iterate():
|
||||
if n_max_combos and combo_id >= n_max_combos:
|
||||
break
|
||||
combo_id += 1
|
||||
t_combo = time.perf_counter()
|
||||
|
||||
for isin, df_asset in assets.items():
|
||||
df_a = df_asset.copy()
|
||||
if col_date in df_a.columns:
|
||||
df_a[col_date] = pd.to_datetime(df_a[col_date], errors="coerce")
|
||||
df_a = df_a.sort_values(col_date).reset_index(drop=True)
|
||||
|
||||
n_obs = len(df_a)
|
||||
try:
|
||||
splits = list(splitter.split(n_obs))
|
||||
except ValueError:
|
||||
# serie troppo corta: skip
|
||||
continue
|
||||
|
||||
exec_ret = exec_ret_map.get(isin) if exec_ret_map else None
|
||||
|
||||
for fold_id, (tr_s, tr_e, te_s, te_e) in enumerate(splits, start=1):
|
||||
# Backtest sul TEST fold (la libreria viene comunque costruita
|
||||
# solo con il passato all'interno della funzione, quindi train
|
||||
# vs test è "purgato" naturalmente per costruzione)
|
||||
df_test = df_a.iloc[tr_s:te_e].copy() # passiamo train+embargo+test
|
||||
exec_ret_test = exec_ret.loc[df_test[col_date]] if (exec_ret is not None and col_date in df_test.columns) else None
|
||||
|
||||
stats = _run_one_combo_on_asset(
|
||||
df_asset=df_test,
|
||||
col_date=col_date,
|
||||
col_ret=col_ret,
|
||||
params=params,
|
||||
exec_ret=exec_ret_test,
|
||||
)
|
||||
|
||||
row = {
|
||||
"ISIN": isin,
|
||||
"combo_id": combo_id,
|
||||
"fold_id": fold_id,
|
||||
"n_obs_test": te_e - te_s,
|
||||
**stats,
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
if verbose:
|
||||
elapsed = time.perf_counter() - t_combo
|
||||
tot_elapsed = time.perf_counter() - t_start
|
||||
eta = (tot_elapsed / combo_id) * (total_combos - combo_id)
|
||||
print(f"[GRID] combo {combo_id}/{total_combos} ({elapsed:.1f}s) — ETA {eta/60:.1f} min")
|
||||
|
||||
# Salvataggio intermedio
|
||||
if save_intermediate_to and (combo_id % 10 == 0):
|
||||
df_partial = pd.DataFrame(rows)
|
||||
try:
|
||||
df_partial.to_excel(save_intermediate_to, index=False)
|
||||
except Exception as e:
|
||||
print(f"[GRID] WARNING: impossibile salvare intermediate: {e}")
|
||||
|
||||
df_results = pd.DataFrame(rows)
|
||||
if verbose:
|
||||
print(f"[GRID] Completato in {(time.perf_counter()-t_start)/60:.1f} min")
|
||||
print(f"[GRID] Righe risultato: {len(df_results):,}")
|
||||
return df_results
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Aggregazione risultati e ranking finale
|
||||
# =============================================================
|
||||
def aggregate_results(
|
||||
df_results: pd.DataFrame,
|
||||
*,
|
||||
by_isin: bool = False,
|
||||
primary_metric: str = "Sharpe",
|
||||
min_trades_per_fold: int = 5,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Aggrega i risultati per combinazione di parametri.
|
||||
|
||||
Per ogni combo_id, calcola:
|
||||
- {primary_metric}_mean → media tra fold (e ISIN se by_isin=False)
|
||||
- {primary_metric}_std → std tra fold
|
||||
- {primary_metric}_min → minimo (robustezza worst-case)
|
||||
- Stability = 1 / (1 + std/|mean|) → 0..1, 1=identico fra fold
|
||||
- N_Trades_avg, MaxDD_avg, Calmar_avg
|
||||
- n_folds_valid → numero di fold con N_Trades >= min_trades_per_fold
|
||||
|
||||
Parameters
|
||||
----------
|
||||
by_isin : bool
|
||||
Se True, aggrega per (ISIN, combo_id) — utile per analizzare quali
|
||||
parametri funzionano per quali asset. Se False, aggrega su tutto.
|
||||
primary_metric : str
|
||||
Metrica principale per il ranking (Sharpe, Calmar, Sortino, ...).
|
||||
min_trades_per_fold : int
|
||||
Fold con meno trade vengono esclusi dall'aggregazione (rumore).
|
||||
|
||||
Returns
|
||||
-------
|
||||
DataFrame ordinato per primary_metric_mean discendente, con i parametri
|
||||
della combinazione (colonne Wp, Ha, k, ecc.) come "chiave".
|
||||
"""
|
||||
if df_results is None or df_results.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# Filtro fold validi
|
||||
df = df_results.copy()
|
||||
df["_valid_fold"] = df["N_Trades"].fillna(0) >= min_trades_per_fold
|
||||
|
||||
# Colonne parametri (chiave della combo)
|
||||
param_cols = [
|
||||
"Wp", "Ha", "k", "theta_entry",
|
||||
"sl_bps", "tp_bps", "trail_bps", "time_stop_bars",
|
||||
"theta_exit", "weak_days_exit",
|
||||
"decision_every", "min_holding_bars", "only_first_signal",
|
||||
]
|
||||
param_cols = [c for c in param_cols if c in df.columns]
|
||||
|
||||
groupby_cols = (["ISIN"] if by_isin else []) + ["combo_id"] + param_cols
|
||||
|
||||
metric_cols = [primary_metric, "CAGR_%", "MaxDD_%eq", "Calmar", "Sortino",
|
||||
"N_Trades", "HitRate_%", "Turnover_%/step", "AvgTradeRet_bps"]
|
||||
metric_cols = [c for c in metric_cols if c in df.columns]
|
||||
|
||||
def _agg(g):
|
||||
valid = g[g["_valid_fold"]]
|
||||
if valid.empty:
|
||||
return pd.Series({
|
||||
f"{primary_metric}_mean": np.nan,
|
||||
f"{primary_metric}_std": np.nan,
|
||||
f"{primary_metric}_min": np.nan,
|
||||
"Stability": np.nan,
|
||||
"n_folds_valid": 0,
|
||||
"n_folds_total": len(g),
|
||||
"N_Trades_avg": np.nan,
|
||||
"MaxDD_avg": np.nan,
|
||||
"Calmar_avg": np.nan,
|
||||
"CAGR_avg": np.nan,
|
||||
"HitRate_avg": np.nan,
|
||||
})
|
||||
|
||||
|
||||
m_mean = valid[primary_metric].mean()
|
||||
m_std = valid[primary_metric].std()
|
||||
m_min = valid[primary_metric].min()
|
||||
stability = 1.0 / (1.0 + (m_std / abs(m_mean))) if (np.isfinite(m_mean) and abs(m_mean) > 1e-9) else 0.0
|
||||
|
||||
return pd.Series({
|
||||
f"{primary_metric}_mean": m_mean,
|
||||
f"{primary_metric}_std": m_std,
|
||||
f"{primary_metric}_min": m_min,
|
||||
"Stability": stability,
|
||||
"n_folds_valid": len(valid),
|
||||
"n_folds_total": len(g),
|
||||
"N_Trades_avg": valid["N_Trades"].mean() if "N_Trades" in valid.columns else np.nan,
|
||||
"MaxDD_avg": valid["MaxDD_%eq"].mean() if "MaxDD_%eq" in valid.columns else np.nan,
|
||||
"Calmar_avg": valid["Calmar"].mean() if "Calmar" in valid.columns else np.nan,
|
||||
"CAGR_avg": valid["CAGR_%"].mean() if "CAGR_%" in valid.columns else np.nan,
|
||||
"HitRate_avg": valid["HitRate_%"].mean() if "HitRate_%" in valid.columns else np.nan,
|
||||
})
|
||||
|
||||
agg = df.groupby(groupby_cols, dropna=False).apply(_agg).reset_index()
|
||||
agg = agg.sort_values(f"{primary_metric}_mean", ascending=False).reset_index(drop=True)
|
||||
|
||||
# Aggiungi un "composite score" che premia Sharpe alto E stabilità alta
|
||||
if f"{primary_metric}_mean" in agg.columns and "Stability" in agg.columns:
|
||||
# Rank percentile sui due indicatori, poi media
|
||||
agg["_rank_metric"] = agg[f"{primary_metric}_mean"].rank(pct=True)
|
||||
agg["_rank_stab"] = agg["Stability"].rank(pct=True)
|
||||
agg["Composite"] = (agg["_rank_metric"] + agg["_rank_stab"]) / 2.0
|
||||
agg = agg.drop(columns=["_rank_metric", "_rank_stab"])
|
||||
|
||||
return agg
|
||||
|
||||
|
||||
def best_combo_per_isin(df_results: pd.DataFrame, primary_metric: str = "Sharpe") -> pd.DataFrame:
|
||||
"""
|
||||
Per ogni ISIN, restituisce la combinazione di parametri con Sharpe-mean
|
||||
più alto. Utile per scoprire se diversi asset preferiscono regimi diversi
|
||||
di (decision_every, tp_bps, ...).
|
||||
"""
|
||||
agg = aggregate_results(df_results, by_isin=True, primary_metric=primary_metric)
|
||||
if agg.empty:
|
||||
return pd.DataFrame()
|
||||
idx = agg.groupby("ISIN")[f"{primary_metric}_mean"].idxmax()
|
||||
return agg.loc[idx.dropna()].reset_index(drop=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ParameterGrid",
|
||||
"TimeSeriesSplitter",
|
||||
"run_grid_search",
|
||||
"aggregate_results",
|
||||
"best_combo_per_isin",
|
||||
]
|
||||
Reference in New Issue
Block a user