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",
|
||||
]
|
||||
282
backtest paper trading/knn_backtest_multiday.py
Normal file
282
backtest paper trading/knn_backtest_multiday.py
Normal file
@@ -0,0 +1,282 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
knn_backtest_multiday.py
|
||||
========================
|
||||
Estensione "multi-day" del walk-forward kNN esistente.
|
||||
|
||||
Filosofia:
|
||||
- NON modifica le funzioni di produzione (knn_forward_backtest_one_asset,
|
||||
build_pattern_library, predict_from_library, z_norm).
|
||||
- Riusa shared_utils.* per coerenza al 100% con il sistema live.
|
||||
- Aggiunge un parametro `decision_every` (N giorni di calendario tra una
|
||||
decisione e la successiva) per ridurre il turnover senza alterare la
|
||||
logica di entry/exit.
|
||||
- Restituisce sia i signals daily sia uno stats dict identico nel formato
|
||||
a quello prodotto dalla funzione daily, così le funzioni di valutazione
|
||||
(ranking, portfolio building, ecc.) possono essere riutilizzate as-is.
|
||||
|
||||
Compatibilità:
|
||||
- decision_every=1 → comportamento IDENTICO al motore daily attuale
|
||||
(è il default, garantisce backward compatibility)
|
||||
- decision_every=N → ricalcola EstOutcome ogni N barre; in mezzo "mantiene
|
||||
in vita" lo stato del segnale e applica solo le exit di rischio (SL/TP/TRAIL/TIME)
|
||||
|
||||
L'idea è coerente con la natura del kNN su finestre WP=60 / Ha=10:
|
||||
non c'è motivo strutturale per rifare la stessa stima ogni 24 ore.
|
||||
|
||||
Author: refactor proposto per il sistema esistente
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Aggiunge la cartella padre al path Python per trovare shared_utils.py
|
||||
_PARENT_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from shared_utils import (
|
||||
build_pattern_library,
|
||||
predict_from_library,
|
||||
z_norm,
|
||||
)
|
||||
|
||||
|
||||
def knn_forward_backtest_multiday(
|
||||
df_isin: pd.DataFrame,
|
||||
col_date: str,
|
||||
col_ret: str,
|
||||
Wp: int,
|
||||
Ha: int,
|
||||
k: int,
|
||||
theta_entry: float,
|
||||
*,
|
||||
exec_ret: Optional[pd.Series] = None,
|
||||
fee_bps: float = 10.0,
|
||||
# --- exit params (stessa semantica del motore daily) ---
|
||||
sl_bps: Optional[float] = 300.0,
|
||||
tp_bps: Optional[float] = 800.0,
|
||||
trail_bps: Optional[float] = 300.0,
|
||||
time_stop_bars: Optional[int] = 20,
|
||||
theta_exit: Optional[float] = 0.0,
|
||||
weak_days_exit: Optional[int] = None,
|
||||
# --- nuovi parametri ---
|
||||
decision_every: int = 1,
|
||||
min_holding_bars: int = 0,
|
||||
# --- nuovi parametri di entry/exit avanzati ---
|
||||
only_first_signal: bool = False, # se True, dopo l'apertura non rivaluta entry
|
||||
) -> tuple[pd.DataFrame, dict]:
|
||||
"""
|
||||
Variante "multi-day" del walk-forward kNN.
|
||||
|
||||
Parametri NUOVI rispetto a knn_forward_backtest_one_asset:
|
||||
--------------------------------------------------------
|
||||
decision_every : int (>=1)
|
||||
Ricalcola EstOutcome e decide entry/flip solo ogni `decision_every`
|
||||
barre. Le exit di rischio (SL/TP/TRAIL/TIME) restano valutate ogni
|
||||
giorno per coerenza con le pratiche di risk management.
|
||||
decision_every=1 → comportamento IDENTICO al motore daily.
|
||||
|
||||
min_holding_bars : int
|
||||
Numero minimo di barre per cui una posizione non può chiudere per
|
||||
RANK/FLIP (le exit di rischio rimangono attive). Utile per evitare
|
||||
di uccidere subito un trade per noise del primo giorno.
|
||||
|
||||
only_first_signal : bool
|
||||
Se True, una volta in posizione non rivaluta EstOutcome per decidere
|
||||
eventuali flip; resta in posizione finché una exit di rischio o un
|
||||
time stop la chiude.
|
||||
|
||||
Tutti gli altri parametri sono identici a knn_forward_backtest_one_asset.
|
||||
|
||||
Returns
|
||||
-------
|
||||
sig_df : DataFrame con colonne ["Date","Signal","EstOutcome","AvgDist","Ret+1","PnL"]
|
||||
stats : dict con le metriche di sintesi (stesso schema della funzione daily)
|
||||
"""
|
||||
if decision_every < 1:
|
||||
raise ValueError("decision_every deve essere >= 1")
|
||||
|
||||
r = pd.to_numeric(df_isin[col_ret], errors="coerce").astype(float) / 100.0
|
||||
idx = df_isin[col_date] if col_date in df_isin.columns else pd.RangeIndex(len(r))
|
||||
idx = pd.to_datetime(idx).dt.normalize()
|
||||
|
||||
if exec_ret is not None:
|
||||
r_exec = pd.to_numeric(exec_ret, errors="coerce").astype(float)
|
||||
r_exec.index = pd.to_datetime(r_exec.index).normalize()
|
||||
r_exec = r_exec.reindex(idx)
|
||||
if len(r_exec) != len(r):
|
||||
r_exec = pd.Series(r_exec.values, index=idx).reindex(idx)
|
||||
else:
|
||||
r_exec = r
|
||||
|
||||
fee = fee_bps / 10000.0
|
||||
|
||||
def _lib_predict(past_returns: pd.Series, win_last: np.ndarray):
|
||||
lib_wins, lib_out = build_pattern_library(past_returns, Wp, Ha)
|
||||
if lib_wins is None:
|
||||
return np.nan, np.nan
|
||||
curr_zn = z_norm(win_last)
|
||||
if curr_zn is None:
|
||||
return np.nan, np.nan
|
||||
est_out, avg_dist, _ = predict_from_library(curr_zn, lib_wins, lib_out, k=k)
|
||||
return float(est_out), float(avg_dist)
|
||||
|
||||
in_pos = False
|
||||
entry_t = None
|
||||
trade_pnl = 0.0
|
||||
trade_peak = 0.0
|
||||
weak_streak = 0
|
||||
# Memorizziamo l'ultima stima per non doverla rifare ogni barra
|
||||
last_est_out: float = np.nan
|
||||
last_avg_dist: float = np.nan
|
||||
|
||||
rows = []
|
||||
|
||||
for t in range(Wp, len(r) - 1):
|
||||
past = r.iloc[:t]
|
||||
next_ret = r_exec.iloc[t + 1] if t + 1 < len(r_exec) else np.nan
|
||||
|
||||
# Decidi se è un giorno di "ranking refresh"
|
||||
is_decision_day = ((t - Wp) % decision_every == 0)
|
||||
|
||||
# Calcola EstOutcome solo nei giorni di decisione (o se non abbiamo ancora una stima)
|
||||
if is_decision_day or pd.isna(last_est_out):
|
||||
if past.dropna().shape[0] < (Wp + Ha):
|
||||
est_out, avg_dist = np.nan, np.nan
|
||||
else:
|
||||
win_last = r.iloc[t - Wp:t].values
|
||||
est_out, avg_dist = _lib_predict(past, win_last)
|
||||
last_est_out = est_out
|
||||
last_avg_dist = avg_dist
|
||||
else:
|
||||
est_out = last_est_out
|
||||
avg_dist = last_avg_dist
|
||||
|
||||
sig_out = 1 if in_pos else 0
|
||||
bars_in_trade = (t - entry_t + 1) if (in_pos and entry_t is not None) else 0
|
||||
|
||||
# === ENTRATA ===
|
||||
# Solo nei giorni di decisione e se non già in posizione
|
||||
if (not in_pos) and is_decision_day and np.isfinite(est_out) and (est_out > theta_entry):
|
||||
sig_out = 1
|
||||
in_pos = True
|
||||
entry_t = t
|
||||
trade_pnl = 0.0
|
||||
trade_peak = 0.0
|
||||
weak_streak = 0
|
||||
|
||||
# === USCITE ===
|
||||
elif in_pos:
|
||||
pnl_if_stay = (1.0 + trade_pnl) * (1.0 + (next_ret if np.isfinite(next_ret) else 0.0)) - 1.0
|
||||
peak_if_stay = max(trade_peak, pnl_if_stay)
|
||||
exit_reasons = []
|
||||
|
||||
# SL/TP/TRAIL: sempre attive (anche nei giorni non-decisione)
|
||||
if (sl_bps is not None) and (pnl_if_stay <= -sl_bps / 10000.0) and (bars_in_trade >= min_holding_bars):
|
||||
exit_reasons.append("SL")
|
||||
if (tp_bps is not None) and (pnl_if_stay >= tp_bps / 10000.0):
|
||||
exit_reasons.append("TP")
|
||||
if (trail_bps is not None) and (peak_if_stay - pnl_if_stay >= trail_bps / 10000.0):
|
||||
exit_reasons.append("TRAIL")
|
||||
if (time_stop_bars is not None) and (bars_in_trade >= time_stop_bars):
|
||||
exit_reasons.append("TIME")
|
||||
|
||||
# FLIP/WEAK: solo nei giorni di decisione e dopo min_holding_bars
|
||||
if (
|
||||
is_decision_day
|
||||
and (not only_first_signal)
|
||||
and (theta_exit is not None)
|
||||
and (bars_in_trade >= min_holding_bars)
|
||||
and np.isfinite(est_out)
|
||||
):
|
||||
if est_out <= theta_exit:
|
||||
weak_streak = weak_streak + 1 if weak_days_exit else weak_streak
|
||||
if weak_days_exit is None:
|
||||
exit_reasons.append("FLIP")
|
||||
elif weak_streak >= weak_days_exit:
|
||||
exit_reasons.append("FLIP_STREAK")
|
||||
else:
|
||||
weak_streak = 0
|
||||
|
||||
if exit_reasons:
|
||||
sig_out = 0
|
||||
in_pos = False
|
||||
entry_t = None
|
||||
trade_pnl = 0.0
|
||||
trade_peak = 0.0
|
||||
weak_streak = 0
|
||||
else:
|
||||
trade_pnl = pnl_if_stay
|
||||
trade_peak = peak_if_stay
|
||||
|
||||
rows.append((idx.iloc[t], sig_out, est_out, avg_dist,
|
||||
r_exec.iloc[t + 1] if t + 1 < len(r_exec) else np.nan))
|
||||
|
||||
sig_df = pd.DataFrame(rows, columns=["Date", "Signal", "EstOutcome", "AvgDist", "Ret+1"])
|
||||
sig_df["Signal_prev"] = sig_df["Signal"].shift(1).fillna(0)
|
||||
trade_chg = (sig_df["Signal"] - sig_df["Signal_prev"]).abs()
|
||||
cost = trade_chg * fee
|
||||
sig_df["PnL"] = sig_df["Signal"] * sig_df["Ret+1"] - cost
|
||||
sig_df.drop(columns=["Signal_prev"], inplace=True)
|
||||
|
||||
# === metriche di sintesi (stesso schema della funzione daily) ===
|
||||
pnl_series = sig_df["PnL"].fillna(0.0)
|
||||
n = max(1, pnl_series.notna().sum())
|
||||
eq = (1.0 + pnl_series).cumprod()
|
||||
peak = eq.cummax()
|
||||
dd = (eq / peak - 1.0)
|
||||
mdd = float(dd.min()) if dd.size else np.nan
|
||||
cagr = float((eq.iloc[-1]) ** (252.0 / max(1, len(pnl_series))) - 1.0) if eq.iloc[-1] > 0 else np.nan
|
||||
sigma = float(pnl_series.std(ddof=1)) if len(pnl_series) > 1 else np.nan
|
||||
vol_a = sigma * np.sqrt(252) if np.isfinite(sigma) else np.nan
|
||||
sharpe = float(pnl_series.mean() / sigma * np.sqrt(252)) if (np.isfinite(sigma) and sigma > 0) else np.nan
|
||||
downside = pnl_series[pnl_series < 0]
|
||||
sortino = (
|
||||
float(pnl_series.mean() / downside.std(ddof=1) * np.sqrt(252))
|
||||
if (len(downside) > 1 and downside.std(ddof=1) > 0)
|
||||
else np.nan
|
||||
)
|
||||
calmar = float(cagr / abs(mdd)) if (np.isfinite(mdd) and mdd < 0 and np.isfinite(cagr)) else np.nan
|
||||
|
||||
# numero di trade reali
|
||||
sig = sig_df["Signal"].astype(int).values
|
||||
n_trades = int(((sig[1:] - sig[:-1]) > 0).sum()) # transizioni 0→1
|
||||
|
||||
stats = {
|
||||
"CAGR_%": round(cagr * 100, 3) if np.isfinite(cagr) else np.nan,
|
||||
"AnnVol_%": round(vol_a * 100, 3) if np.isfinite(vol_a) else np.nan,
|
||||
"Sharpe": round(sharpe, 3) if np.isfinite(sharpe) else np.nan,
|
||||
"Sortino": round(sortino, 3) if np.isfinite(sortino) else np.nan,
|
||||
"MaxDD_%eq": round(mdd * 100, 3) if np.isfinite(mdd) else np.nan,
|
||||
"Calmar": round(calmar, 3) if np.isfinite(calmar) else np.nan,
|
||||
"HitRate_%": round(100 * (pnl_series > 0).sum() / max(1, (pnl_series != 0).sum()), 2),
|
||||
"AvgTradeRet_bps": round(pnl_series.mean() * 10000, 2),
|
||||
"Turnover_%/step": round(100 * trade_chg.mean(), 2),
|
||||
"N_Steps": int(sig_df.shape[0]),
|
||||
"N_Trades": n_trades,
|
||||
# parametri usati (per tracciare la cella della grid search)
|
||||
"Wp": int(Wp),
|
||||
"Ha": int(Ha),
|
||||
"k": int(k),
|
||||
"theta_entry": float(theta_entry),
|
||||
"theta_exit": (None if theta_exit is None else float(theta_exit)),
|
||||
"sl_bps": (None if sl_bps is None else float(sl_bps)),
|
||||
"tp_bps": (None if tp_bps is None else float(tp_bps)),
|
||||
"trail_bps": (None if trail_bps is None else float(trail_bps)),
|
||||
"time_stop_bars": (None if time_stop_bars is None else int(time_stop_bars)),
|
||||
"weak_days_exit": (None if weak_days_exit is None else int(weak_days_exit)),
|
||||
"decision_every": int(decision_every),
|
||||
"min_holding_bars": int(min_holding_bars),
|
||||
"only_first_signal": bool(only_first_signal),
|
||||
}
|
||||
|
||||
return sig_df, stats
|
||||
|
||||
|
||||
__all__ = ["knn_forward_backtest_multiday"]
|
||||
195
backtest paper trading/report.py
Normal file
195
backtest paper trading/report.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
report.py
|
||||
=========
|
||||
Generazione di report (XLSX + PNG) dei risultati della grid search.
|
||||
|
||||
- write_full_report(df_results, agg, output_dir)
|
||||
→ genera 4 file:
|
||||
1) grid_search_full.xlsx (tutti i fold, una riga per (ISIN, combo, fold))
|
||||
2) grid_search_aggregate.xlsx (aggregato per combo, ordinato per Sharpe medio)
|
||||
3) heatmap_*.png (heatmap delle metriche su coppie di parametri)
|
||||
4) top_combos.xlsx (top-K combinazioni con confronto con baseline)
|
||||
|
||||
L'idea è che il PM possa aprire SOLO grid_search_aggregate.xlsx e capire
|
||||
in 30 secondi quale set di parametri usare in produzione.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
def write_full_report(
|
||||
df_results: pd.DataFrame,
|
||||
agg: pd.DataFrame,
|
||||
output_dir: Path,
|
||||
*,
|
||||
primary_metric: str = "Sharpe",
|
||||
top_k: int = 25,
|
||||
baseline_params: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Scrive tutti i file di output e ritorna un dict con i path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
df_results : DataFrame "lungo" prodotto da run_grid_search
|
||||
agg : DataFrame aggregato prodotto da aggregate_results
|
||||
output_dir : Path
|
||||
primary_metric : str
|
||||
top_k : int
|
||||
Numero di top combinazioni da analizzare in dettaglio
|
||||
baseline_params : dict, opzionale
|
||||
Parametri della config attuale di produzione, per confronto. Esempio:
|
||||
{"Wp":60, "Ha":10, "k":25, "theta_entry":0.005, "sl_bps":300, "tp_bps":800,
|
||||
"trail_bps":300, "time_stop_bars":20, "decision_every":1, "min_holding_bars":0}
|
||||
"""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths = {}
|
||||
|
||||
# === 1. Dataset completo ===
|
||||
path_full = output_dir / "grid_search_full.xlsx"
|
||||
df_results.to_excel(path_full, index=False)
|
||||
paths["full"] = path_full
|
||||
print(f"[REPORT] Salvato {path_full} ({len(df_results):,} righe)")
|
||||
|
||||
# === 2. Aggregato per combinazione ===
|
||||
path_agg = output_dir / "grid_search_aggregate.xlsx"
|
||||
with pd.ExcelWriter(path_agg, engine="openpyxl") as xw:
|
||||
agg.to_excel(xw, sheet_name="ByCombo", index=False)
|
||||
if baseline_params is not None:
|
||||
baseline_row = _find_baseline_row(agg, baseline_params)
|
||||
if baseline_row is not None:
|
||||
pd.DataFrame([baseline_row]).to_excel(xw, sheet_name="Baseline", index=False)
|
||||
paths["aggregate"] = path_agg
|
||||
print(f"[REPORT] Salvato {path_agg}")
|
||||
|
||||
# === 3. Top-K combinazioni ===
|
||||
top = agg.head(top_k).copy()
|
||||
if baseline_params is not None:
|
||||
baseline_row = _find_baseline_row(agg, baseline_params)
|
||||
if baseline_row is not None:
|
||||
metric_mean = f"{primary_metric}_mean"
|
||||
base_metric = baseline_row.get(metric_mean, np.nan)
|
||||
top["delta_vs_baseline"] = top[metric_mean] - base_metric
|
||||
top["pct_improvement"] = (top[metric_mean] / base_metric - 1) * 100 if base_metric and base_metric != 0 else np.nan
|
||||
|
||||
path_top = output_dir / "top_combos.xlsx"
|
||||
top.to_excel(path_top, index=False)
|
||||
paths["top"] = path_top
|
||||
print(f"[REPORT] Salvato {path_top}")
|
||||
|
||||
# === 4. Heatmaps ===
|
||||
heatmap_paths = _generate_heatmaps(agg, output_dir, primary_metric=primary_metric)
|
||||
paths.update(heatmap_paths)
|
||||
|
||||
# === 5. Top combos equity comparison ===
|
||||
# (Skipped if no per-fold equity data — left as hook for future enhancement)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _find_baseline_row(agg: pd.DataFrame, baseline_params: dict) -> Optional[dict]:
|
||||
"""Trova la riga corrispondente ai parametri baseline (se presente)."""
|
||||
mask = pd.Series(True, index=agg.index)
|
||||
for k, v in baseline_params.items():
|
||||
if k not in agg.columns:
|
||||
continue
|
||||
if v is None:
|
||||
mask &= agg[k].isna()
|
||||
else:
|
||||
mask &= (agg[k] == v)
|
||||
if mask.sum() == 0:
|
||||
return None
|
||||
return agg[mask].iloc[0].to_dict()
|
||||
|
||||
|
||||
def _generate_heatmaps(
|
||||
agg: pd.DataFrame,
|
||||
output_dir: Path,
|
||||
*,
|
||||
primary_metric: str = "Sharpe",
|
||||
) -> dict:
|
||||
"""
|
||||
Per ogni coppia "interessante" di parametri, genera una heatmap
|
||||
del primary_metric (mediato sugli altri parametri).
|
||||
"""
|
||||
paths = {}
|
||||
metric_col = f"{primary_metric}_mean"
|
||||
if metric_col not in agg.columns:
|
||||
return paths
|
||||
|
||||
pairs_to_plot = [
|
||||
("decision_every", "tp_bps", "decision_vs_tp"),
|
||||
("decision_every", "sl_bps", "decision_vs_sl"),
|
||||
("tp_bps", "sl_bps", "tp_vs_sl"),
|
||||
("Wp", "Ha", "wp_vs_ha"),
|
||||
("decision_every", "min_holding_bars", "decision_vs_holding"),
|
||||
("k", "theta_entry", "k_vs_theta"),
|
||||
]
|
||||
|
||||
for px, py, label in pairs_to_plot:
|
||||
if px not in agg.columns or py not in agg.columns:
|
||||
continue
|
||||
if agg[px].nunique() <= 1 or agg[py].nunique() <= 1:
|
||||
continue
|
||||
try:
|
||||
pivot = agg.pivot_table(index=py, columns=px, values=metric_col, aggfunc="mean")
|
||||
n_pivot = agg.pivot_table(index=py, columns=px, values="N_Trades_avg", aggfunc="mean")
|
||||
fig, ax = plt.subplots(figsize=(8, 5.5), dpi=120)
|
||||
im = ax.imshow(pivot.values, cmap="RdYlGn", aspect="auto")
|
||||
ax.set_xticks(range(len(pivot.columns)))
|
||||
ax.set_xticklabels(pivot.columns, fontsize=9)
|
||||
ax.set_yticks(range(len(pivot.index)))
|
||||
ax.set_yticklabels(pivot.index, fontsize=9)
|
||||
ax.set_xlabel(px, fontsize=10)
|
||||
ax.set_ylabel(py, fontsize=10)
|
||||
ax.set_title(f"{primary_metric} medio per {px} × {py} (annotazione: N_Trades_avg)", fontsize=10)
|
||||
# Annota celle: metrica sopra, n_trades sotto
|
||||
for i in range(pivot.shape[0]):
|
||||
for j in range(pivot.shape[1]):
|
||||
val = pivot.iloc[i, j]
|
||||
if not np.isfinite(val):
|
||||
continue
|
||||
n_val = n_pivot.iloc[i, j] if not n_pivot.empty else np.nan
|
||||
text = f"{val:.2f}"
|
||||
if np.isfinite(n_val):
|
||||
text += f"\nn={int(n_val)}"
|
||||
ax.text(j, i, text, ha="center", va="center", fontsize=7,
|
||||
color="black" if abs(val) < 0.8 else "white")
|
||||
fig.colorbar(im, ax=ax, label=primary_metric)
|
||||
plt.tight_layout()
|
||||
p = output_dir / f"heatmap_{label}.png"
|
||||
fig.savefig(p, dpi=120, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
paths[f"heatmap_{label}"] = p
|
||||
print(f"[REPORT] Salvata heatmap {p}")
|
||||
except Exception as e:
|
||||
print(f"[REPORT] Heatmap {label} fallita: {e}")
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def summary_text_table(agg: pd.DataFrame, top_k: int = 10, primary_metric: str = "Sharpe") -> str:
|
||||
"""
|
||||
Restituisce una tabella testuale dei top-K combinazioni, formattata per la console.
|
||||
"""
|
||||
if agg is None or agg.empty:
|
||||
return "(nessun risultato)"
|
||||
cols_to_show = [
|
||||
"Wp", "Ha", "k", "decision_every", "tp_bps", "sl_bps", "trail_bps",
|
||||
"min_holding_bars",
|
||||
f"{primary_metric}_mean", "Stability", "N_Trades_avg",
|
||||
"CAGR_avg", "MaxDD_avg", "Calmar_avg",
|
||||
]
|
||||
cols_to_show = [c for c in cols_to_show if c in agg.columns]
|
||||
return agg[cols_to_show].head(top_k).to_string(index=False)
|
||||
|
||||
|
||||
__all__ = ["write_full_report", "summary_text_table"]
|
||||
344
backtest paper trading/run_optimization.py
Normal file
344
backtest paper trading/run_optimization.py
Normal file
@@ -0,0 +1,344 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
run_optimization.py
|
||||
===================
|
||||
Script principale per lanciare la grid search sul sistema kNN.
|
||||
|
||||
Riusa load_config() e read_connection_txt() di shared_utils per leggere
|
||||
i prezzi storici dallo stesso DB usato in produzione.
|
||||
|
||||
Workflow:
|
||||
1) Carica universo Excel
|
||||
2) Per ogni ISIN, scarica la serie dal DB (cached optional)
|
||||
3) Definisce ParameterGrid e TimeSeriesSplitter
|
||||
4) Lancia run_grid_search
|
||||
5) Aggrega e genera report
|
||||
|
||||
Uso:
|
||||
python run_optimization.py # esegue con grid e splitter di default
|
||||
python run_optimization.py --mode quick # grid ridotta (~50 combo, ~30 minuti)
|
||||
python run_optimization.py --mode full # grid completa (~2000 combo, ~24h)
|
||||
python run_optimization.py --mode multiday # focus su decision_every e tp/sl
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Aggiunge la cartella padre al path Python così trova shared_utils.py
|
||||
# (shared_utils.py sta nella cartella del progetto, accanto a 'backtest_optimizer/')
|
||||
_PARENT_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from shared_utils import (
|
||||
detect_column,
|
||||
load_config,
|
||||
read_connection_txt,
|
||||
require_section,
|
||||
require_value,
|
||||
)
|
||||
|
||||
from grid_search import ParameterGrid, TimeSeriesSplitter, run_grid_search, aggregate_results, best_combo_per_isin
|
||||
from report import write_full_report, summary_text_table
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Preset grid: scegli quello adeguato all'uso
|
||||
# =====================================================
|
||||
def make_grid_preset(preset: str) -> ParameterGrid:
|
||||
if preset == "quick":
|
||||
# ~36 combo, focus sui parametri "in top of mind"
|
||||
return ParameterGrid(
|
||||
Wp=[60],
|
||||
Ha=[10],
|
||||
k=[25],
|
||||
theta_entry=[0.005],
|
||||
sl_bps=[300.0],
|
||||
tp_bps=[800.0, 1200.0],
|
||||
trail_bps=[200.0, 300.0],
|
||||
time_stop_bars=[20],
|
||||
theta_exit=[0.0],
|
||||
weak_days_exit=[None],
|
||||
decision_every=[1, 3, 5, 10],
|
||||
min_holding_bars=[0, 3, 5],
|
||||
only_first_signal=[False],
|
||||
fee_bps=[10.0],
|
||||
)
|
||||
if preset == "multiday":
|
||||
# Focus sulla domanda "esteso multi-giorno": ~96 combo
|
||||
return ParameterGrid(
|
||||
Wp=[60],
|
||||
Ha=[10],
|
||||
k=[25],
|
||||
theta_entry=[0.005],
|
||||
sl_bps=[200.0, 300.0, 500.0],
|
||||
tp_bps=[600.0, 800.0, 1200.0, 1500.0],
|
||||
trail_bps=[200.0, 300.0],
|
||||
time_stop_bars=[20],
|
||||
theta_exit=[0.0],
|
||||
weak_days_exit=[None],
|
||||
decision_every=[1, 2, 3, 5, 10, 20],
|
||||
min_holding_bars=[0, 3],
|
||||
only_first_signal=[False],
|
||||
fee_bps=[10.0],
|
||||
)
|
||||
if preset == "wide":
|
||||
# Esplora anche Wp, Ha, k: ~1500 combo
|
||||
return ParameterGrid(
|
||||
Wp=[40, 60, 80, 120],
|
||||
Ha=[5, 10, 15, 20],
|
||||
k=[15, 25, 35],
|
||||
theta_entry=[0.003, 0.005, 0.01],
|
||||
sl_bps=[200.0, 300.0, 500.0],
|
||||
tp_bps=[600.0, 800.0, 1200.0],
|
||||
trail_bps=[200.0, 300.0],
|
||||
time_stop_bars=[15, 20, 30],
|
||||
theta_exit=[0.0],
|
||||
weak_days_exit=[None],
|
||||
decision_every=[1, 3, 5, 10],
|
||||
min_holding_bars=[0, 3],
|
||||
only_first_signal=[False],
|
||||
fee_bps=[10.0],
|
||||
)
|
||||
if preset == "full":
|
||||
# Tutto: 5000+ combo, da lanciare su cluster o overnight
|
||||
return ParameterGrid(
|
||||
Wp=[40, 60, 80, 120],
|
||||
Ha=[5, 10, 15, 20],
|
||||
k=[10, 15, 25, 35, 50],
|
||||
theta_entry=[0.0, 0.003, 0.005, 0.01],
|
||||
sl_bps=[200.0, 300.0, 500.0, None],
|
||||
tp_bps=[600.0, 800.0, 1200.0, 1500.0, None],
|
||||
trail_bps=[200.0, 300.0, None],
|
||||
time_stop_bars=[15, 20, 30, None],
|
||||
theta_exit=[0.0, -0.005],
|
||||
weak_days_exit=[None, 3],
|
||||
decision_every=[1, 2, 3, 5, 10],
|
||||
min_holding_bars=[0, 3, 5],
|
||||
only_first_signal=[False, True],
|
||||
fee_bps=[10.0],
|
||||
)
|
||||
raise ValueError(f"Preset sconosciuto: {preset}")
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Caricamento dati dal DB (riusa stessa logica di produzione)
|
||||
# =====================================================
|
||||
def load_asset_data(
|
||||
isins: list[str],
|
||||
engine: sa.Engine,
|
||||
stored_proc: str,
|
||||
n_bars: int,
|
||||
ptf_curr: str,
|
||||
*,
|
||||
min_bars: int = 500,
|
||||
cache_dir: Optional[Path] = None,
|
||||
) -> dict[str, pd.DataFrame]:
|
||||
"""
|
||||
Per ogni ISIN, esegue la SP e ritorna un dict {isin: df}.
|
||||
Cache su disco in formato Parquet per accelerare i run successivi.
|
||||
"""
|
||||
sql = text(f"EXEC {stored_proc} @ISIN = :isin, @n = :n, @PtfCurr = :ptf")
|
||||
out = {}
|
||||
for i, isin in enumerate(isins, 1):
|
||||
# Cache check
|
||||
if cache_dir is not None:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = cache_dir / f"{isin}.parquet"
|
||||
if cache_file.exists():
|
||||
try:
|
||||
df = pd.read_parquet(cache_file)
|
||||
if len(df) >= min_bars:
|
||||
out[isin] = df
|
||||
if i % 10 == 0:
|
||||
print(f" [DATA] {i}/{len(isins)} (cache hit per {isin})")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
df = pd.read_sql_query(sql, engine, params={"isin": isin, "n": n_bars, "ptf": ptf_curr})
|
||||
if df.empty or len(df) < min_bars:
|
||||
continue
|
||||
out[isin] = df
|
||||
if cache_dir is not None:
|
||||
try:
|
||||
df.to_parquet(cache_file, index=False)
|
||||
except Exception as e:
|
||||
print(f" [DATA] cache write fallita per {isin}: {e}")
|
||||
if i % 10 == 0:
|
||||
print(f" [DATA] {i}/{len(isins)}")
|
||||
except Exception as e:
|
||||
print(f" [DATA] {isin}: errore {e}")
|
||||
|
||||
print(f"[DATA] Caricati {len(out)}/{len(isins)} ISIN")
|
||||
return out
|
||||
|
||||
|
||||
def detect_data_cols(df: pd.DataFrame) -> tuple[str, str]:
|
||||
col_date = detect_column(df, ["Date", "Data", "Datetime", "Timestamp", "Time"])
|
||||
col_ret = detect_column(df, ["Ret", "Return", "Rendimento", "Rend", "LogRet", "r_log", "pct_chg"])
|
||||
return col_date, col_ret
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Main
|
||||
# =====================================================
|
||||
def main(args):
|
||||
cfg = load_config()
|
||||
db_cfg = require_section(cfg, "db")
|
||||
paths_cfg = require_section(cfg, "paths")
|
||||
pattern_cfg = require_section(cfg, "pattern")
|
||||
signals_cfg = cfg.get("signals", {})
|
||||
|
||||
# ---- Output dirs ----
|
||||
output_dir = Path(args.output_dir or "output/optimization")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_dir = Path(args.cache_dir or "output/optimization/asset_cache")
|
||||
|
||||
# ---- Universo ----
|
||||
universo_xlsx = paths_cfg.get("input_universe", "Input/Universo per Trading System.xlsx")
|
||||
universo = pd.read_excel(universo_xlsx)
|
||||
col_isin = detect_column(universo, ["ISIN", "isin"])
|
||||
if col_isin is None:
|
||||
raise ValueError("Colonna ISIN non trovata nell'universo")
|
||||
isins = universo[col_isin].astype(str).str.strip().replace("", pd.NA).dropna().drop_duplicates().tolist()
|
||||
if args.max_isin:
|
||||
isins = isins[:args.max_isin]
|
||||
print(f"[MAIN] Universo: {len(isins)} ISIN")
|
||||
|
||||
# ---- Connessione DB e caricamento dati ----
|
||||
if args.skip_db:
|
||||
# Modalità test: usa solo dati cached
|
||||
assets = {}
|
||||
for isin in isins:
|
||||
cf = cache_dir / f"{isin}.parquet"
|
||||
if cf.exists():
|
||||
try:
|
||||
assets[isin] = pd.read_parquet(cf)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[MAIN] (skip-db) Caricati {len(assets)} ISIN dalla cache")
|
||||
else:
|
||||
conn_str = read_connection_txt("connection.txt")
|
||||
engine = sa.create_engine(conn_str, fast_executemany=True)
|
||||
print("[MAIN] Connesso al DB")
|
||||
assets = load_asset_data(
|
||||
isins=isins,
|
||||
engine=engine,
|
||||
stored_proc=db_cfg["stored_proc"],
|
||||
n_bars=int(db_cfg["n_bars"]),
|
||||
ptf_curr=str(db_cfg["ptf_curr"]),
|
||||
cache_dir=cache_dir,
|
||||
)
|
||||
|
||||
if not assets:
|
||||
print("[MAIN] Nessun asset disponibile, esco.")
|
||||
return
|
||||
|
||||
# ---- Detect colonne ----
|
||||
sample_df = next(iter(assets.values()))
|
||||
col_date, col_ret = detect_data_cols(sample_df)
|
||||
if not col_date or not col_ret:
|
||||
raise ValueError(f"Colonne data/ret non riconosciute: {sample_df.columns.tolist()}")
|
||||
print(f"[MAIN] Date col = '{col_date}', Ret col = '{col_ret}'")
|
||||
|
||||
# ---- Grid e splitter ----
|
||||
grid = make_grid_preset(args.mode)
|
||||
splitter = TimeSeriesSplitter(
|
||||
n_splits=args.n_splits,
|
||||
train_size=args.train_size,
|
||||
test_size=args.test_size,
|
||||
embargo=args.embargo,
|
||||
)
|
||||
print(f"[MAIN] Grid preset='{args.mode}' → {grid.size()} combinazioni")
|
||||
print(f"[MAIN] Splitter: {args.n_splits} fold, train={args.train_size}, test={args.test_size}, embargo={args.embargo}")
|
||||
|
||||
# ---- Esecuzione ----
|
||||
t_start = time.perf_counter()
|
||||
df_results = run_grid_search(
|
||||
assets=assets,
|
||||
col_date=col_date,
|
||||
col_ret=col_ret,
|
||||
grid=grid,
|
||||
splitter=splitter,
|
||||
verbose=True,
|
||||
n_max_combos=args.max_combos,
|
||||
save_intermediate_to=output_dir / "grid_search_partial.xlsx",
|
||||
)
|
||||
print(f"[MAIN] Grid search completata in {(time.perf_counter()-t_start)/60:.1f} min")
|
||||
|
||||
if df_results.empty:
|
||||
print("[MAIN] Nessun risultato — esco.")
|
||||
return
|
||||
|
||||
# ---- Aggregazione ----
|
||||
agg = aggregate_results(df_results, by_isin=False, primary_metric=args.primary_metric,
|
||||
min_trades_per_fold=args.min_trades_per_fold)
|
||||
|
||||
# ---- Baseline corrente di produzione ----
|
||||
baseline_params = {
|
||||
"Wp": int(pattern_cfg["wp"]),
|
||||
"Ha": int(pattern_cfg["ha"]),
|
||||
"k": int(pattern_cfg["knn_k"]),
|
||||
"theta_entry": float(pattern_cfg["theta"]),
|
||||
"sl_bps": float(signals_cfg.get("sl_bps", 300.0)),
|
||||
"tp_bps": float(signals_cfg.get("tp_bps", 800.0)),
|
||||
"trail_bps": float(signals_cfg.get("trail_bps", 300.0)),
|
||||
"time_stop_bars": int(signals_cfg.get("time_stop_bars", 20)),
|
||||
"decision_every": 1,
|
||||
"min_holding_bars": 0,
|
||||
}
|
||||
|
||||
# ---- Report ----
|
||||
paths = write_full_report(
|
||||
df_results=df_results,
|
||||
agg=agg,
|
||||
output_dir=output_dir,
|
||||
primary_metric=args.primary_metric,
|
||||
top_k=args.top_k,
|
||||
baseline_params=baseline_params,
|
||||
)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"TOP-{args.top_k} COMBINAZIONI ({args.primary_metric}-mean)")
|
||||
print("=" * 70)
|
||||
print(summary_text_table(agg, top_k=args.top_k, primary_metric=args.primary_metric))
|
||||
print("\nFile generati:")
|
||||
for k, p in paths.items():
|
||||
print(f" {k}: {p}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description="Grid search per il sistema kNN")
|
||||
p.add_argument("--mode", choices=["quick", "multiday", "wide", "full"], default="quick",
|
||||
help="Preset della grid (default: quick)")
|
||||
p.add_argument("--n-splits", type=int, default=4, help="Numero di fold walk-forward (default: 4)")
|
||||
p.add_argument("--train-size", type=int, default=504, help="Lunghezza fold di train (default: 504)")
|
||||
p.add_argument("--test-size", type=int, default=126, help="Lunghezza fold di test (default: 126)")
|
||||
p.add_argument("--embargo", type=int, default=20, help="Embargo train-test (default: 20)")
|
||||
p.add_argument("--max-isin", type=int, default=None, help="Limita il numero di ISIN (debug)")
|
||||
p.add_argument("--max-combos", type=int, default=None, help="Limita il numero di combo (debug)")
|
||||
p.add_argument("--min-trades-per-fold", type=int, default=5,
|
||||
help="Fold con meno trade vengono scartati (default: 5)")
|
||||
p.add_argument("--primary-metric", type=str, default="Sharpe",
|
||||
help="Metrica di ranking (Sharpe, Calmar, Sortino, CAGR_%%)")
|
||||
p.add_argument("--top-k", type=int, default=25, help="Numero di top combo nel report (default: 25)")
|
||||
p.add_argument("--output-dir", type=str, default=None)
|
||||
p.add_argument("--cache-dir", type=str, default=None)
|
||||
p.add_argument("--skip-db", action="store_true", help="Usa solo dati cached, non interroga il DB")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(parse_args())
|
||||
Reference in New Issue
Block a user