← Torna agli articoli
March 11, 2026
5 min di lettura

Discesa per Coordinate vs Ottimizzazione Bayesiana: Chi Trova i Parametri Migliori

Discesa per Coordinate vs Ottimizzazione Bayesiana: Chi Trova i Parametri Migliori
#algotrading
#backtest
#ottimizzazione
#Optuna
#TPE
#ottimizzazione bayesiana
#discesa per coordinate
#iperparametri

Questo è il quinto articolo della serie "Backtest Senza Illusioni". Negli articoli precedenti abbiamo trattato l'asimmetria perdita-profitto, il bootstrap Monte Carlo, l'impatto dei tassi di finanziamento e la cache Parquet per backtest più veloci. Parliamo ora del processo di ricerca dei parametri ottimali della strategia — un compito in cui l'intuizione fallisce più spesso.

Hai una strategia con 12 parametri. Ogni parametro prende ~9 valori. Vuoi trovare la combinazione che massimizza il PnL con un drawdown limitato. Come lo fai?

Se la tua risposta è "itero attraverso tutte le combinazioni" — hai un problema. Se la tua risposta è "cambio un parametro alla volta" — hai un problema diverso. Questo articolo parla di quali problemi si nascondono dietro ogni approccio e come risolverli.

Perché la Ricerca Esaustiva è Impossibile

La maledizione della dimensionalità: crescita esponenziale dello spazio di ricerca

La Maledizione della Dimensionalità

La ricerca esaustiva (grid search) testa ogni combinazione di valori per ogni parametro. Per due parametri con 9 valori, sono 92=819^2 = 81 esecuzioni — perfettamente fattibile. Per tre: 93=7299^3 = 729 — tollerabile.

Ma per una strategia reale con 12 parametri:

Ngrid=912=282,429,536,481N_{grid} = 9^{12} = 282{,}429{,}536{,}481

Duecentottantadue miliardi di esecuzioni. Anche se un singolo backtest richiede 1 secondo (il che è già ottimistico), la ricerca esaustiva richiederebbe:

T=282×1093600×24×3658,950 anniT = \frac{282 \times 10^{9}}{3600 \times 24 \times 365} \approx 8{,}950 \text{ anni}

Questa è una crescita esponenziale: ogni nuovo parametro moltiplica lo spazio di ricerca per 9. Aggiungi un 13° parametro — e invece di 9.000 anni ne servono 80.000.

import math

def grid_search_cost(n_params: int, values_per_param: int, seconds_per_trial: float) -> dict:
    """Stima il costo della ricerca esaustiva."""
    total_trials = values_per_param ** n_params
    total_seconds = total_trials * seconds_per_trial
    return {
        "total_trials": total_trials,
        "total_hours": total_seconds / 3600,
        "total_years": total_seconds / (3600 * 24 * 365),
    }

cost = grid_search_cost(12, 9, 1.0)
print(f"Trials: {cost['total_trials']:,.0f}")      # 282,429,536,481
print(f"Anni:   {cost['total_years']:,.0f}")        # 8,950

Anche con il Precalcolo

Nell'articolo sulla cache Parquet abbiamo mostrato come il precalcolo dei timeframe e degli indicatori velocizza un singolo backtest a ~1 secondo. Ma anche a 0,1 secondi per esecuzione, la ricerca esaustiva di 12 parametri richiederebbe 895 anni. Il precalcolo aiuta, ma non risolve il problema fondamentale della crescita esponenziale.

Abbiamo bisogno di metodi che esplorino lo spazio dei parametri in modo più intelligente della ricerca esaustiva.

Discesa per Coordinate e OAT: Veloci ma Cieche

Esplorazione dello spazio dei parametri: OAT vs ottimizzazione bayesiana

Due Varianti della Stessa Idea

Esistono due approcci correlati — entrambi ottimizzano un parametro alla volta, ma differiscono nel numero di passaggi:

OAT (One-at-a-Time) sweep — un singolo passaggio attraverso tutti i parametri. Itera sui valori del primo parametro, fissa il migliore, passa al secondo — e così via. Una volta sola. Veloce ed economico.

Discesa per Coordinate — multipassaggio. Dopo aver ottimizzato l'ultimo parametro, torna al primo e verifica se l'ottimo è cambiato (poiché il contesto è cambiato — i valori degli altri parametri sono ora diversi). Ripeti i cicli fino alla convergenza. Più costosa, ma più precisa — ogni ciclo può affinare la soluzione.

In pratica, per i backtest si usa più spesso OAT: un singolo passaggio attraverso 12 parametri — 96 esecuzioni. La discesa per coordinate con 3-5 cicli — 300-500 esecuzioni, che è già paragonabile a Optuna, ma senza i suoi vantaggi.

Per 12 parametri con ~8 valori ciascuno:

NOAT=K×N=12×8=96 esecuzioniN_{OAT} = K \times N = 12 \times 8 = 96 \text{ esecuzioni}

Confronta con 282×109282 \times 10^9 per la grid search. OAT è lineare: O(KN)O(K \cdot N) invece di O(NK)O(N^K). Questo è sia il suo principale vantaggio sia il suo principale problema.

def oat_sweep(
    param_grid: dict[str, list],
    run_backtest_fn,
    initial_params: dict,
    metric: str = "effective_score",
) -> dict:
    """
    OAT sweep: singolo passaggio, ottimizzando un parametro alla volta.

    param_grid: {"htf_entry_sell": [0.0, 0.005, ..., 0.05], ...}
    initial_params: valori iniziali per tutti i parametri
    metric: metrica da ottimizzare (effective_score raccomandato —
            PnL per tempo attivo estrapolato a un anno)
    """
    best_params = initial_params.copy()
    best_score = run_backtest_fn(**best_params)[metric]

    for param_name, values in param_grid.items():
        param_best_val = best_params[param_name]
        param_best_score = best_score

        for val in values:
            candidate = best_params.copy()
            candidate[param_name] = val
            result = run_backtest_fn(**candidate)
            score = result[metric]

            if score > param_best_score:
                param_best_score = score
                param_best_val = val

        best_params[param_name] = param_best_val
        best_score = param_best_score
        print(f"{param_name}: best={param_best_val}, score={param_best_score:.4f}")

    return best_params

Quale metrica scegliere per l'ottimizzazione? Invece del PnL grezzo o PnL@MaxLev, si raccomanda di usare l'effective score — PnL per tempo attivo estrapolato a un anno. Questa metrica tiene conto del tempo in posizione e permette un confronto corretto tra strategie con frequenze di trading diverse.

Il Punto Cieco: Interazioni tra Parametri

OAT assume che l'effetto di ogni parametro sia additivo — cioè, il valore ottimale di un parametro non dipende dai valori degli altri. Questa assunzione regge per alcuni parametri, ma si rompe per quelli accoppiati.

Parametri Additivi vs Accoppiati

Prima di ottimizzare — è utile classificare i parametri:

Additivi (indipendenti) — il valore ottimale di uno non dipende dall'altro. Possono essere ottimizzati uno alla volta a basso costo:

  • htf_entry_sell e htf_entry_buy — soglie di ingresso per diverse direzioni (vendita/acquisto) sullo stesso timeframe. La soglia di vendita filtra i segnali short, quella di acquisto — i long. Operano su sottoinsiemi di trade non sovrapposti.
  • tp_target e be_trigger — take-profit e breakeven, se non creano condizioni di uscita conflittuali.

Accoppiati (interattivi) — il valore ottimale di uno dipende dall'altro. È necessaria un'ottimizzazione congiunta:

  • htf_entry_sell e mtf_entry_sell — soglie per la stessa direzione (vendita) su timeframe diversi. HTF determina quali segnali raggiungono MTF, e la soglia MTF determina l'efficacia del filtraggio. L'ottimo HTF si sposta quando cambia MTF.
  • ltf_entry_sell, mtf_entry_sell, htf_entry_sell — l'intera catena di soglie per una direzione.
  • partial_frac e tp_target — la dimensione della chiusura parziale dipende dal livello TP.

Approccio pratico: prima ottimizza a basso costo i parametri additivi tramite OAT. Poi ottimizza i gruppi accoppiati tramite Optuna. Questo riduce il budget: invece di 12 parametri in Optuna, si inviano solo 6-8 accoppiati, mentre gli altri sono già fissati.

Esempio: Come OAT Perde un'Interazione

Considera due soglie accoppiate:

  • htf_entry_sell — soglia sul timeframe superiore (direzione vendita)
  • mtf_entry_sell — soglia sul timeframe medio (direzione vendita)

OAT fissa mtf_entry_sell = 0.01 (valore iniziale) e itera su htf_entry_sell. Trova il valore migliore: htf_entry_sell = 0.02. Lo fissa e passa al parametro successivo — non torna mai indietro.

Ecco cosa OAT ha perso:

htf_entry_sell mtf_entry_sell PnL
0.02 0.01 +42%
0.02 0.02 +38%
0.03 0.02 +51%
0.03 0.01 +35%

La combinazione (0.03, 0.02) produce un PnL +51%, ma OAT non la considererà mai perché con mtf_entry_sell = 0.01 fisso, il valore htf_entry_sell = 0.03 produce solo +35%. OAT si è "bloccato" nell'ottimo locale (0.02, 0.01) e non riesce a vedere l'ottimo globale (0.03, 0.02).

Questo è un problema classico: se il paesaggio della funzione obiettivo contiene creste diagonali (quando l'ottimo di un parametro si sposta al variare di un altro), OAT le perde.

Formalizzare il Problema

Sia f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) la funzione obiettivo (PnL). OAT trova un punto dove:

fθi=0i\frac{\partial f}{\partial \theta_i} = 0 \quad \forall i

Ma questa è una condizione necessaria, non sufficiente per un ottimo globale. Se la matrice Hessiana Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} ha elementi fuori diagonale significativi — OAT non tiene conto delle derivate incrociate 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} quando iji \neq j.

Per i parametri accoppiati (soglie della stessa direzione su più timeframe) — le interazioni sono la regola, non l'eccezione. La soglia di ingresso sul timeframe superiore determina quali segnali raggiungono quello medio, e la soglia su quello medio determina l'efficacia del filtraggio su quello inferiore. Per i parametri additivi (direzioni diverse, filtri indipendenti) le derivate incrociate sono vicine a zero — e OAT funziona bene.

Ottimizzazione Bayesiana: Ricerca Intelligente

Ottimizzazione bayesiana: modello surrogato della funzione obiettivo

L'Idea

Invece dell'enumerazione cieca o della ricerca greedy, l'ottimizzazione bayesiana costruisce un modello surrogato della funzione obiettivo e ad ogni passo seleziona il punto in cui il miglioramento atteso è massimo.

Algoritmo:

  1. Scegli diversi punti casuali, valuta la funzione obiettivo
  2. Costruisci un modello surrogato (approssima f(θ)f(\theta) dai punti osservati)
  3. Trova il punto con il massimo miglioramento atteso (funzione di acquisizione)
  4. Valuta la funzione obiettivo in quel punto
  5. Aggiorna il modello surrogato
  6. Ripeti i passi 3-5

La differenza chiave rispetto a OAT: l'ottimizzazione bayesiana considera tutti i parametri simultaneamente e può esplorare le creste diagonali nello spazio dei parametri.

TPE (Tree-structured Parzen Estimator)

Campionatore TPE: modellazione delle distribuzioni di parametri buoni e cattivi

TPE è il campionatore predefinito in Optuna. Invece di modellare f(θ)f(\theta) direttamente, TPE modella due distribuzioni:

  • l(θ)l(\theta) — distribuzione dei parametri dove la funzione obiettivo è migliore della soglia yy^*
  • g(θ)g(\theta) — distribuzione dei parametri dove la funzione obiettivo è peggiore della soglia yy^*

La funzione di acquisizione di TPE — il rapporto:

EI(θ)l(θ)g(θ)\text{EI}(\theta) \propto \frac{l(\theta)}{g(\theta)}

TPE seleziona i punti dove l(θ)l(\theta) è grande (parametri simili a quelli "buoni") e g(θ)g(\theta) è piccolo (parametri non simili a quelli "cattivi").

Perché TPE è adatto per i backtest:

  • Gestisce le dipendenze condizionali tra parametri
  • Non richiede la continuità della funzione obiettivo
  • Efficiente con budget moderati (100-1000 iterazioni)
  • Supporta parametri categorici e discreti

Processo Gaussiano (GP)

Un'alternativa a TPE — il Processo Gaussiano. GP modella f(θ)f(\theta) come un processo normale multivariato e fornisce non solo una previsione del valore, ma anche l'incertezza in ogni punto.

f(θ)GP(m(θ),  k(θ,θ))f(\theta) \sim \mathcal{GP}\bigl(m(\theta),\; k(\theta, \theta')\bigr)

dove m(θ)m(\theta) è la media, k(θ,θ)k(\theta, \theta') è la funzione di covarianza (kernel).

GP funziona bene quando:

  • I parametri sono pochi (fino a 10-15)
  • La funzione obiettivo è liscia
  • Ogni esecuzione è costosa (minuti, ore)

Per i backtest con una cache Parquet precalcolata, dove una singola esecuzione richiede ~1 secondo, TPE è di solito preferito: costruisce il modello più velocemente e scala meglio a 500+ iterazioni.

Integrazione Pratica con Optuna

Framework di ottimizzazione Optuna: ricerca iterativa dei parametri

Esempio Completo Funzionante

import optuna
from optuna.samplers import TPESampler
import numpy as np


def run_backtest(htf_pre, mtf_pre, ltf_pre, **params) -> dict:
    """
    Esegue un backtest con i parametri dati.
    Restituisce un dict con metriche: pnl, max_dd, n_trades, trading_time, sharpe.
    Usa la cache Parquet precalcolata — ~1 secondo per esecuzione.
    """
    pass


def objective(trial: optuna.Trial) -> float:
    """Funzione obiettivo per Optuna."""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),

        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),

        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),

        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),

        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    return -result["pnl_at_max_lev"]


study = optuna.create_study(
    sampler=TPESampler(seed=42),
    study_name="strategy_optimization",
    direction="minimize",
)

study.optimize(objective, n_trials=500, show_progress_bar=True)

print(f"Miglior PnL: {-study.best_value:.2f}%")
print(f"Migliori parametri: {study.best_params}")
print(f"Totale trials: {len(study.trials)}")

A ~1 secondo per backtest (con cache precalcolata):

T500=500×1s8 minutiT_{500} = 500 \times 1\text{s} \approx 8 \text{ minuti}

Otto minuti contro 8.950 anni di ricerca esaustiva. E TPE in 500 iterazioni trova combinazioni che OAT perde in 96, perché esplora lo spazio dei parametri simultaneamente invece che asse per asse.

Salvare e Riprendere uno Studio

import optuna

study = optuna.create_study(
    storage="sqlite:///optuna_study.db",
    study_name="strategy_v2",
    sampler=TPESampler(seed=42),
    direction="minimize",
    load_if_exists=True,  # continua se lo studio esiste già
)

study.optimize(objective, n_trials=300)


study.optimize(objective, n_trials=200)

Aggiungere Vincoli

Non tutte le combinazioni di parametri sono valide. Per esempio, la soglia di uscita non dovrebbe superare quella di ingresso:

def objective_with_constraints(trial: optuna.Trial) -> float:
    htf_entry = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    htf_exit  = trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005)

    if htf_exit > htf_entry:
        raise optuna.TrialPruned()

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)
    return -result["pnl_at_max_lev"]

Confronto dei Campionatori

Confronto della convergenza dei campionatori nel corso delle iterazioni

Optuna supporta diversi campionatori. Ognuno ha i propri punti di forza.

TPESampler (predefinito)

sampler = optuna.samplers.TPESampler(
    n_startup_trials=20,  # trial casuali prima che inizi la modellazione
    seed=42,
)
  • Principio: Tree-structured Parzen Estimator
  • Punti di forza: buono per tipi di parametri misti, scala a 1000+ iterazioni
  • Punti deboli: può essere meno efficiente con forti interazioni tra parametri
  • Quando usarlo: di default, se non c'è ragione di scegliere un altro

CmaEsSampler

sampler = optuna.samplers.CmaEsSampler(seed=42)
  • Principio: Covariance Matrix Adaptation Evolution Strategy — un algoritmo evolutivo che adatta la matrice di covarianza
  • Punti di forza: eccellente nel trovare interazioni tra parametri continui, tiene conto delle correlazioni
  • Punti deboli: non supporta parametri categorici, richiede più iterazioni per l'inizializzazione
  • Quando usarlo: se tutti i parametri sono continui e si sospettano forti interazioni

GPSampler

sampler = optuna.samplers.GPSampler(seed=42)
  • Principio: Processo Gaussiano con funzione di acquisizione
  • Punti di forza: migliore efficienza del campionamento (meno iterazioni per un buon risultato), fornisce stime dell'incertezza
  • Punti deboli: O(n3)O(n^3) nel numero di iterazioni — lento quando n>200n > 200
  • Quando usarlo: se un singolo backtest è costoso (minuti) e il budget è limitato a 100-200 iterazioni

RandomSampler (baseline)

sampler = optuna.samplers.RandomSampler(seed=42)
  • Principio: campionamento casuale uniforme
  • Punti di forza: non si blocca negli ottimi locali, copertura completa dello spazio
  • Punti deboli: non usa i risultati precedenti
  • Quando usarlo: come baseline per il confronto, o per l'analisi esplorativa

QMCSampler

sampler = optuna.samplers.QMCSampler(seed=42)
  • Principio: Quasi-Monte Carlo (sequenze di Sobol/Halton) — riempie lo spazio in modo più uniforme di un campionatore casuale
  • Punti di forza: migliore copertura dello spazio rispetto a RandomSampler, riproducibilità
  • Punti deboli: non si adatta ai risultati
  • Quando usarlo: per le prime 50-100 iterazioni prima di passare a TPE

Tabella Riassuntiva

Campionatore Tipo Interazioni Categorici Budget Ottimale
TPE Bayesiano Parziale 100-1000
CmaEs Evolutivo No 200-2000
GP Bayesiano Limitato 50-200
Random Casuale No Qualsiasi (baseline)
QMC Quasi-casuale No No 50-500

Benchmark Pratico

import optuna
import time

def benchmark_sampler(sampler, n_trials=300):
    """Confronta i campionatori sullo stesso compito."""
    study = optuna.create_study(sampler=sampler, direction="minimize")

    start = time.time()
    study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
    elapsed = time.time() - start

    return {
        "best_value": -study.best_value,
        "elapsed_sec": elapsed,
        "best_trial": study.best_trial.number,
    }

samplers = {
    "TPE":    optuna.samplers.TPESampler(seed=42),
    "CmaEs":  optuna.samplers.CmaEsSampler(seed=42),
    "GP":     optuna.samplers.GPSampler(seed=42),
    "Random": optuna.samplers.RandomSampler(seed=42),
    "QMC":    optuna.samplers.QMCSampler(seed=42),
}

for name, sampler in samplers.items():
    result = benchmark_sampler(sampler, n_trials=300)
    print(f"{name:8s}: miglior PnL={result['best_value']:.2f}%, "
          f"trovato al trial #{result['best_trial']}, "
          f"tempo={result['elapsed_sec']:.1f}s")

Risultati tipici per una strategia con 12 parametri:

Campionatore Miglior PnL Trovato all'Iterazione Overhead del Campionatore
TPE ~51% ~180 Basso
CmaEs ~49% ~250 Medio
GP ~48% ~90 Alto quando n>200n > 200
Random ~42% ~270 Minimo
QMC ~43% ~200 Minimo

TPE e CmaEs superano consistentemente la ricerca casuale del 15-20% nel PnL finale. GP trova buoni risultati prima ma raggiunge un tetto computazionale con un gran numero di iterazioni.

Ottimizzazione Multi-Obiettivo: PnL vs MaxDD

Fronte di Pareto: compromesso tra PnL e drawdown massimo

Perché un Singolo Criterio Non Basta

Massimizzare il PnL senza vincoli sul drawdown è la strada verso il disastro. Una strategia con PnL +80% e MaxDD -30% è, a causa dell'asimmetria perdita-profitto, significativamente più rischiosa di una strategia con PnL +50% e MaxDD -5%.

Il problema di ottimizzazione è in realtà multi-obiettivo:

maxθ  PnL(θ)subject toMaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{subject to} \quad \text{MaxDD}(\theta) \to \min

Questi obiettivi sono in conflitto: i parametri aggressivi aumentano sia il PnL che il drawdown. La soluzione non è un singolo punto, ma un fronte di Pareto: un insieme di soluzioni in cui non è possibile migliorare una metrica senza peggiorare l'altra.

NSGA-II / NSGA-III in Optuna

import optuna

def multi_objective(trial: optuna.Trial) -> tuple[float, float]:
    """Funzione multi-obiettivo: (PnL, MaxDD)."""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),
        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),
        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),
        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),
        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    pnl = result["pnl"]          # massimizza
    max_dd = result["max_dd"]    # minimizza (già un numero negativo)

    return pnl, max_dd  # Optuna: entrambe le direzioni sono impostate in create_study


study = optuna.create_study(
    directions=["maximize", "minimize"],
    sampler=optuna.samplers.NSGAIIISampler(seed=42),
    study_name="multi_objective_strategy",
)

study.optimize(multi_objective, n_trials=500)

pareto_trials = study.best_trials
print(f"Fronte di Pareto: {len(pareto_trials)} soluzioni")

for t in pareto_trials[:5]:
    print(f"  PnL={t.values[0]:.2f}%, MaxDD={t.values[1]:.2f}%")

Selezionare un Punto sul Fronte di Pareto

Il fronte di Pareto fornisce molteplici soluzioni. Come sceglierne una?

def select_from_pareto(
    pareto_trials: list,
    max_dd_limit: float = -5.0,
    min_pnl: float = 20.0,
) -> list:
    """
    Filtra il fronte di Pareto per vincoli.

    max_dd_limit: drawdown massimo accettabile (es. -5%)
    min_pnl: PnL minimo accettabile (%)
    """
    filtered = []
    for trial in pareto_trials:
        pnl, max_dd = trial.values
        if max_dd >= max_dd_limit and pnl >= min_pnl:
            max_lev = min(50 / abs(max_dd), 100) if max_dd != 0 else 100
            pnl_at_max_lev = pnl * max_lev
            filtered.append({
                "trial": trial,
                "pnl": pnl,
                "max_dd": max_dd,
                "max_lev": max_lev,
                "pnl_at_max_lev": pnl_at_max_lev,
            })

    filtered.sort(key=lambda x: x["pnl_at_max_lev"], reverse=True)
    return filtered

Nota: nel calcolo del PnL alla leva massima, è necessario tenere conto dei tassi di finanziamento, altrimenti una leva teoricamente elevata si trasformerà in una perdita sul mercato reale. Inoltre, il PnL finale è una stima puntuale, e per valutare la stabilità dei risultati è necessario il bootstrap Monte Carlo.

Esempio: Tre Strategie sul Fronte di Pareto

Strategia PnL MaxDD MaxLev PnL@MaxLev Tempo di trading
Strategia A ~55% ~0.9% ~55x ~3025% ~15%
Strategia B ~25% ~0.75% ~66x ~1650% ~5%
Strategia C ~300% ~17% ~3x ~900% ~45%

La Strategia C con un impressionante PnL di +300% risulta essere la meno attraente per PnL@MaxLev a causa dell'elevato drawdown. La Strategia A guida nel rendimento netto con leva, ma tenendo conto del PnL per tempo attivo, la Strategia B può essere preferibile — il 95% del tempo libero può essere riempito con altre strategie.

Grafici a Contorno e Importanza dei Parametri

Grafici a contorno: visualizzazione delle interazioni tra parametri e plateau

Visualizzazione del Paesaggio

Dopo l'ottimizzazione — la visualizzazione. Optuna fornisce strumenti integrati:

import optuna.visualization as vis

fig_contour = vis.plot_contour(
    study,
    params=["htf_entry_sell", "mtf_entry_sell"],
)
fig_contour.show()

fig_importance = vis.plot_param_importances(study)
fig_importance.show()

fig_history = vis.plot_optimization_history(study)
fig_history.show()

fig_parallel = vis.plot_parallel_coordinate(
    study,
    params=["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell"],
)
fig_parallel.show()

fig_slice = vis.plot_slice(study)
fig_slice.show()

Grafico a Contorno: Leggere le Interazioni

Un grafico a contorno costruisce una sezione bidimensionale della funzione obiettivo per una coppia di parametri. Se le isolinee sono parallele a uno degli assi — i parametri non interagiscono, e OAT avrebbe trovato lo stesso ottimo. Se le isolinee sono diagonali — c'è interazione, e OAT la perderà.

key_params = ["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell",
              "htf_entry_buy",  "mtf_entry_buy",  "ltf_entry_buy"]

for i, p1 in enumerate(key_params):
    for p2 in key_params[i+1:]:
        fig = vis.plot_contour(study, params=[p1, p2])
        fig.write_image(f"contour_{p1}_vs_{p2}.png")

Se un grafico a contorno mostra un plateau — una regione dove la funzione obiettivo cambia poco — questo è un buon segno. Un plateau significa che il risultato è robusto a piccole deviazioni dei parametri. Di più sull'analisi dei plateau e sulla sua relazione con l'overfitting — nell'articolo in arrivo Analisi del plateau.

Importanza dei Parametri

importance = optuna.importance.get_param_importances(study)
for param, imp in importance.items():
    print(f"{param:20s}: {imp:.4f}")

Output tipico:

htf_entry_sell      : 0.2841
mtf_entry_sell      : 0.2103
ltf_entry_sell      : 0.1567
trail_pct           : 0.1204
htf_entry_buy       : 0.0892
...

I parametri con importanza < 0.01 possono essere fissati al loro valore predefinito — questo riduce la dimensionalità del problema e velocizza l'ottimizzazione. Ma attenzione: una bassa importanza può anche significare che il parametro è importante solo nell'interazione con altri. Verifica tramite grafici a contorno.

Cache Precalcolata: Perché 1 Secondo per Backtest Cambia Tutto

Cache Parquet precalcolata: accelerazione dei backtest da ore a secondi

La velocità di un singolo backtest determina quale metodo di ottimizzazione puoi permetterti.

Tempo Backtest 96 OAT 500 TPE 2000 CmaEs
60 secondi 1.6 ore 8.3 ore 33 ore
10 secondi 16 minuti 83 minuti 5.5 ore
1 secondo 1.5 minuti 8 minuti 33 minuti
0.1 secondi 10 secondi 50 secondi 3.3 minuti

A 60 secondi per backtest, 500 iterazioni TPE richiedono 8 ore. Già tollerabile, ma iterare (cambiare la funzione obiettivo, riavviare) è costoso. A 1 secondo — 8 minuti, e puoi eseguire decine di esperimenti al giorno.

Ecco precisamente perché il precalcolo nella cache Parquet non è solo un'ottimizzazione della velocità, ma un'espansione dello spazio dei metodi disponibili. Senza cache sei limitato a OAT o 100 iterazioni GP. Con cache — puoi permetterti 2000 iterazioni CmaEs o un NSGA-III multi-obiettivo completo.

import pyarrow.parquet as pq
import time

t0 = time.time()
htf_pre = pq.read_table("cache/htf_indicators.parquet").to_pandas()
mtf_pre = pq.read_table("cache/mtf_indicators.parquet").to_pandas()
ltf_pre = pq.read_table("cache/ltf_indicators.parquet").to_pandas()
print(f"Cache caricata in {time.time() - t0:.2f}s")  # ~0.3s

t1 = time.time()
result = run_backtest(htf_pre, mtf_pre, ltf_pre, htf_entry_sell=0.02, ...)
print(f"Backtest in {time.time() - t1:.2f}s")  # ~1.0s

Raccomandazioni Pratiche

Approccio ibrido: combinare OAT e ottimizzazione bayesiana

Quando Usare OAT

OAT è giustificato nei seguenti casi:

  1. Analisi esplorativa. Stai iniziando a esplorare una strategia e vuoi capire quali parametri influenzano il risultato. 96 esecuzioni in 1.5 minuti — un ottimo punto di partenza.

  2. Parametri additivi. Per i parametri che operano su sottoinsiemi di trade non sovrapposti (direzioni vendita vs acquisto, strumenti diversi), OAT darà un risultato corretto più velocemente.

  3. Backtest molto costoso. Se una singola esecuzione richiede 10+ minuti e non può essere velocizzata, OAT con 96 esecuzioni (16 ore) è preferibile a 500 iterazioni TPE (3.5 giorni).

Quando Usare Optuna

Optuna è preferibile nella maggior parte dei casi:

  1. Più di 3 parametri. Le interazioni sono praticamente garantite — OAT perderà l'ottimo.

  2. Strategie multi-timeframe. Le soglie su diversi timeframe sono quasi sempre interconnesse.

  3. Ottimizzazione finale. Quando la strategia ha superato il bootstrap Monte Carlo e sei sicuro della sua robustezza — Optuna troverà i parametri migliori.

  4. Problemi multi-obiettivo. PnL vs MaxDD vs tempo di trading — OAT non può risolvere questo problema in linea di principio.

Approccio Ibrido: OAT per Additivi + Optuna per Accoppiati

Non devi scegliere tra OAT e Optuna — è meglio combinarli:

  1. Classifica i parametri. Dividi in additivi (indipendenti) e accoppiati (interattivi). Esempio per 12 parametri di separazione:

    • Additivi: htf_entry_sell <-> htf_entry_buy, mtf_entry_sell <-> mtf_entry_buy, ltf_entry_sell <-> ltf_entry_buy (vendita/acquisto — direzioni diverse, operano su trade non sovrapposti)
    • Gruppo accoppiato vendita: htf_entry_sell, mtf_entry_sell, ltf_entry_sell (catena di filtraggio: HTF -> MTF -> LTF per i segnali di vendita)
    • Gruppo accoppiato acquisto: htf_entry_buy, mtf_entry_buy, ltf_entry_buy
  2. OAT per additivi. Ottimizza i gruppi vendita e acquisto indipendentemente. Se i parametri di vendita non influenzano i trade di acquisto — OAT darà un risultato corretto in minuti.

  3. Optuna per accoppiati. All'interno di ogni gruppo (vendita: 6 parametri entrata+uscita) usa TPE. 6 parametri invece di 12 — il budget è dimezzato.

sell_params = oat_sweep(sell_param_grid, run_backtest, initial_params)

def objective_sell(trial):
    params = sell_params.copy()
    params["htf_entry_sell"] = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    params["mtf_entry_sell"] = trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005)
    params["ltf_entry_sell"] = trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005)
    params["htf_exit_sell"] = trial.suggest_float("htf_exit_sell", 0.0, 0.02, step=0.001)
    params["mtf_exit_sell"] = trial.suggest_float("mtf_exit_sell", 0.0, 0.02, step=0.001)
    params["ltf_exit_sell"] = trial.suggest_float("ltf_exit_sell", 0.0, 0.02, step=0.001)
    return -run_backtest(**params)["effective_score"]

study = optuna.create_study(sampler=optuna.samplers.TPESampler())
study.optimize(objective_sell, n_trials=300)  # 6 parametri → 300 sono sufficienti

Pipeline di Ottimizzazione Completa

1. Precalcola la cache Parquet (una volta sola)
2. Classifica i parametri: additivi vs accoppiati
3. OAT per additivi (~50 esecuzioni, ~1 min) → fissa
4. Optuna TPE per gruppi accoppiati (300 iterazioni x 2 gruppi, ~10 min)
5. Optuna NSGA-III per meta-parametri (500 iterazioni, ~8 min) → fronte di Pareto
6. Grafici a contorno → visualizza le interazioni
7. Bootstrap Monte Carlo dei punti migliori → intervalli di confidenza
8. Walk-Forward → validazione out-of-sample

Il passo 8 — ottimizzazione walk-forward — è fondamentale per la protezione contro l'overfitting. Di più su questo nell'articolo in arrivo Walk-Forward.

Insidie dell'Ottimizzazione

Overfitting. Più parametri e più precisa è l'ottimizzazione — maggiore è il rischio di adattare la strategia ai dati storici. 500 iterazioni Optuna con 12 parametri troveranno una combinazione che funziona perfettamente sul set di addestramento, ma è inutile su nuovi dati.

Protezione:

Problema dei confronti multipli. Se testi 500 combinazioni, la probabilità di trovare casualmente un risultato "buono" cresce. La correzione di Bonferroni o il controllo FDR (False Discovery Rate) aiutano, ma l'approccio più semplice è la validazione out-of-sample.

Budget insufficiente. TPE con 50 iterazioni per 12 parametri è troppo poco. Le prime 20 iterazioni sono casuali (startup), lasciando solo 30 per la modellazione. Budget minimo: 10×K=12010 \times K = 120 iterazioni per 12 parametri, raccomandato: 3050×K30\text{--}50 \times K.

Freqtrade: Come Funziona in un Framework di Produzione

Freqtrade: framework di trading algoritmico con integrazione Optuna

Freqtrade — uno dei framework di algotrading più popolari — usa Optuna internamente attraverso il modulo Hyperopt. La sua esperienza conferma le nostre raccomandazioni:

  • Campionatori: TPE (predefinito), GP, CmaEs, NSGA-II, QMC — tutti disponibili tramite configurazione
  • Funzioni di perdita: 12 funzioni di perdita integrate, incluse ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
  • Multi-obiettivo: supporto per NSGA-II e NSGA-III per l'ottimizzazione simultanea di più metriche
  • Campionatori personalizzati: possibilità di collegare qualsiasi campionatore compatibile con Optuna

Una lezione chiave dall'ecosistema Freqtrade: le funzioni di perdita integrate coprono scenari tipici, ma per un'ottimizzazione seria è necessaria una funzione obiettivo personalizzata che tenga conto delle specificità della tua strategia — tempo attivo, costi di finanziamento, drill-down adattivo per una simulazione accurata degli eseguiti.

Conclusione

Pipeline di ottimizzazione completa: dai dati ai parametri validati

La discesa per coordinate (OAT) è un metodo veloce e intuitivo. Per 12 parametri richiede solo 96 esecuzioni e termina in un minuto e mezzo. Ma è cieca alle interazioni tra parametri — e nelle strategie multi-timeframe, le interazioni sono quasi sempre presenti.

L'ottimizzazione bayesiana tramite Optuna (TPE, GP, CmaEs) esplora lo spazio dei parametri nel suo insieme. 500 iterazioni in 8 minuti — con una cache Parquet precalcolata — trovano combinazioni invisibili a OAT.

L'ottimizzazione multi-obiettivo (NSGA-III) trasforma il problema di "massimizzare il PnL" nel problema di "costruire un fronte di Pareto PnL vs MaxDD" — e fornisce un insieme di soluzioni con diversi compromessi rischio-rendimento.

Ma l'ottimizzazione è solo una parte della pipeline. I parametri trovati devono essere validati tramite bootstrap Monte Carlo, corretti per i tassi di finanziamento, ricalcolati tenendo conto del tempo attivo e sottoposti a validazione walk-forward. Di più su questo nei prossimi articoli della serie.


Link Utili

  1. Optuna: A Next-generation Hyperparameter Optimization Framework (Akiba et al., 2019)
  2. Algorithms for Hyper-Parameter Optimization (Bergstra et al., 2011) — il paper originale su TPE
  3. Optuna Documentation — Samplers
  4. Optuna Visualization Module
  5. Hansen, N. — The CMA Evolution Strategy: A Tutorial
  6. Deb, K. et al. — NSGA-II: A Fast and Elitist Multiobjective Genetic Algorithm (2002)
  7. Snoek, J. et al. — Practical Bayesian Optimization of Machine Learning Algorithms (2012)
  8. Freqtrade Documentation — Hyperopt
  9. Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12
  10. Bergstra, J. & Bengio, Y. — Random Search for Hyper-Parameter Optimization (2012)

Citazione

@article{soloviov2026optuna,
  author = {Soloviov, Eugen},
  title = {Discesa per Coordinate vs Ottimizzazione Bayesiana: Chi Trova i Parametri Migliori},
  year = {2026},
  url = {https://marketmaker.cc/it/blog/post/optuna-vs-coordinate-descent},
  description = {Perché la ricerca esaustiva è impossibile per 12+ parametri, come la discesa per coordinate perde le interazioni e come Optuna con un campionatore TPE trova in 500 iterazioni ciò che OAT non riesce a trovare in 96.}
}
Disclaimer: le informazioni fornite in questo articolo hanno solo scopo didattico e informativo e non costituiscono consulenza finanziaria, di investimento o di trading. Il trading di criptovalute comporta un rischio significativo di perdita.

Autori

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Resta un Passo Avanti al Mercato

Iscriviti alla nostra newsletter per approfondimenti esclusivi sul trading con IA, analisi di mercato e aggiornamenti sulla piattaforma.

Rispettiamo la tua privacy. Annulla l'iscrizione in qualsiasi momento.