Files
Encelado/Encelado/tools/Encelado.Backtest/Program.cs
T
Alby96andClaude Fable 5.1 b39e08b15c Aggiunge il meta-modello, il database locale e la pipeline di ricerca della guida
Il bot ora ha un secondo parere prima di ogni ingresso: un classificatore GBDT
(scritto in C#, senza dipendenze native) addestrato sugli esiti dei segnali passati
con triple-barrier e meta-labeling, validato con CPCV, PBO e Sharpe deflazionato
contando tutte le configurazioni provate. Il modello non propone mai operazioni:
può solo rifiutarne una sotto la probabilità minima o ridurne la size, e si
sospende da solo quando le feature dal vivo derivano da quelle di addestramento.
Senza un campione promosso il bot opera come prima.

Perché tutto questo serve, e nell'ordine in cui è stato fatto:

- I log del giro reale sul testnet mostravano zero barre chiuse in tre giorni: il
  decodificatore saltava l'oggetto annidato dei kline. Corretto con test di
  regressione. Lo stesso giro restava a 1499/1500 barre di riscaldamento perché
  Binance ne serve al massimo 1500 per richiesta: il client ora pagina e il motore
  chiede quante ne servono davvero.
- Il log è diventato una tabella `;` con data, livello, sorgente, evento ed
  eccezione (grep `;ERR;` trova ogni errore), con rotazione a dimensione impostabile
  dalla finestra. Anche decisions.csv/executions.csv/trades.csv hanno intestazione
  stabile, id monotoni e colonna `motivazione`, e vengono scritti anche in SQLite.
- La configurazione vive in Documenti\Encelado (con migrazione dal file accanto
  all'eseguibile), le credenziali restano in LocalAppData, il database in
  %ProgramData%\Encelado: tre cartelle per tre ruoli diversi.
- In modalità demo gli ordini partono davvero sul testnet (dryRun spento di
  fabbrica): è l'unico modo di provare il percorso di esecuzione come in produzione.
- Lo strumento di backtest copre le fasi 0-4 della guida: qualità dei dati,
  baseline buy&hold/SMA con PSR e DSR, Engle-Granger + Johansen + Kalman con costo
  di break-even, dataset e addestramento del meta-modello, DQN su molti seed.
  Ogni tabella è CSV `;` con motivazione, e la promozione a campione avviene solo
  se il modello supera i criteri della Fase 3.

Sui dati disponibili nessuna coppia supera quei criteri, quindi nessun campione è
stato promosso: il bot resta sulla sola regola statistica, che a sua volta non
regge fuori campione. Il risultato è documentato, non nascosto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:19:14 +02:00

701 lines
31 KiB
C#

using System.Globalization;
using Encelado.Core.Backtest;
using Encelado.Core.Market;
using Encelado.Core.Strategies;
namespace Encelado.Backtest;
/// <summary>
/// The development tool that answers the only question worth asking before changing a
/// setting: does this hold up on data I did not use to choose it?
/// <para>
/// Three commands. <c>run</c> replays one pair and prints what it did. <c>sweep</c> grids
/// the thresholds and ranks them — on the <i>validation</i> slice, never on the slice the
/// grid was scored on. <c>confirm</c> takes the survivors and reports them on a third
/// slice that nothing has touched, which is the only number that means anything.
/// </para>
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
try
{
return Run(args);
}
catch (Exception ex)
{
Console.Error.WriteLine($"errore: {ex.Message}");
return 1;
}
}
private static int Run(string[] args)
{
if (args.Length == 0)
{
Usage();
return 2;
}
Options options = Options.Parse(args);
return args[0].ToLowerInvariant() switch
{
"run" => CommandRun(options),
"sweep" => CommandSweep(options),
"confirm" => CommandConfirm(options),
"pairs" => CommandPairs(options),
"explore" => CommandExplore(options),
"basket" => CommandBasket(options),
"inspect" => Research.Inspect(options),
"import" => Research.Import(options),
"baseline" => Research.BaselineCommand(options),
"cointegration" => Research.Cointegration(options),
"dataset" => Research.Dataset(options),
"train" => Research.Train(options),
"rl" => Research.Rl(options),
"status" => Research.Status(options),
_ => Usage(),
};
}
private static int Usage()
{
Console.WriteLine("""
Encelado backtest dell'arbitraggio statistico su coppie
backtest run --a ETHUSDT --b BTCUSDT [opzioni]
backtest sweep --a ETHUSDT --b BTCUSDT [opzioni]
backtest confirm --a ETHUSDT --b BTCUSDT [opzioni]
backtest pairs [opzioni]
backtest explore --a ETHUSDT --b BTCUSDT [opzioni] (solo esplorazione)
backtest basket [opzioni] (sceglie i default)
Dati
--data <cartella> cartella con i file <SIMBOLO>.csv (obbligatoria)
--tf 5m|15m|1h timeframe su cui ripiegare le barre da un minuto (default 5m)
Modello
--entry, --exit, --stop, --window, --maxbars
--calib <barre> finestra della regressione di cointegrazione (default 500)
--recal <barre> ogni quante barre si rifitta (default: un giorno)
--fee <bps> commissione taker per lato (default 4)
--slip <bps> slippage per lato (default 1)
--fit-once fitta beta una volta sola su tutto il file (con look-ahead)
--no-coint non richiedere la cointegrazione (solo ricerca)
Suddivisione
--split a:b:c proporzioni taratura:verifica:conferma (default 50:25:25)
La classifica dello sweep è ordinata sulla fetta di VERIFICA. La fetta di
CONFERMA non entra mai nella scelta: serve solo a dire quanto è sopravvissuto.
Ricerca (le fasi della guida; ogni tabella è CSV ';' con colonna motivazione)
backtest inspect --data <cartella> FASE 0: qualità dei file a un minuto
backtest import --data <cartella> [--tf 15m] barre canoniche nel database
backtest baseline --symbol BTCUSDT [--fast 20 --slow 50] FASE 1: buy&hold e medie mobili
backtest cointegration --a ETHUSDT --b BTCUSDT FASE 2: Engle-Granger, Johansen, Kalman, break-even
backtest dataset --a ETHUSDT --b BTCUSDT FASE 3: dataset di meta-labeling (triple barrier)
backtest train --a ETHUSDT --b BTCUSDT [--promote] FASE 3: GBDT + CPCV, PBO, DSR, campione
backtest rl --a ETHUSDT --b BTCUSDT [--seeds 15 --episodes 2] FASE 4: DQN multi-seed
backtest status conteggi delle tabelle del database
--db <file> database SQLite (default: quello del bot in %ProgramData%\Encelado)
--no-db non toccare il database
--out <cartella> dove scrivere le tabelle (default: cartella 'ricerca' accanto allo strumento)
--pt, --sl barriere di profitto e stop in volatilità locali (default 2 e 1)
--label-max <barre> barriera verticale (default: un giorno)
--trials <n> quante configurazioni GBDT provare (default: tutte e sei)
Un modello diventa campione solo con --promote e solo se supera i criteri della
Fase 3 (PBO < 0.5, DSR > 0.95 contando tutte le prove, Sharpe fuori campione > 0).
""");
return 2;
}
// -----------------------------------------------------------------------
// Commands
// -----------------------------------------------------------------------
private static int CommandRun(Options o)
{
PairData data = PairData.Load(o);
PairBacktestSettings settings = o.Settings();
Console.WriteLine();
Console.WriteLine($" {o.SymbolA} / {o.SymbolB} {data.Count} barre {o.TimeFrameText} " +
$"{data.From:yyyy-MM-dd} → {data.To:yyyy-MM-dd}");
Console.WriteLine($" costo per cambio di posizione: {settings.RoundCost:P4} " +
$"({settings.TakerFeeBps:F1} bps fee + {settings.SlippageBps:F1} bps slippage)");
Console.WriteLine();
PairCalibrationSchedule schedule = settings.FitOnce
? PairCalibrationSchedule.Precompute(data.CloseA, data.CloseB, settings with { RecalibrateEveryBars = int.MaxValue })
: PairCalibrationSchedule.Precompute(data.CloseA, data.CloseB, settings);
PairBacktestReport report = PairBacktest.Run(
$"{o.SymbolA}/{o.SymbolB}", data.BarsA, data.BarsB, o.Parameters(), settings, schedule);
Report(report, settings);
return 0;
}
private static int CommandPairs(Options o)
{
string[][] basket =
[
["ETHUSDT", "BTCUSDT"],
["SOLUSDT", "AVAXUSDT"],
["SOLUSDT", "ETHUSDT"],
["AVAXUSDT", "ETHUSDT"],
["SOLUSDT", "BTCUSDT"],
["AVAXUSDT", "BTCUSDT"],
];
Console.WriteLine();
Console.WriteLine(" Coppie disponibili nella cartella dati, con i parametri correnti:");
Console.WriteLine();
foreach (string[] pair in basket)
{
if (!File.Exists(Path.Combine(o.DataDirectory, pair[0] + ".csv")) ||
!File.Exists(Path.Combine(o.DataDirectory, pair[1] + ".csv")))
{
continue;
}
Options local = o with { SymbolA = pair[0], SymbolB = pair[1] };
PairData data = PairData.Load(local);
PairBacktestSettings settings = local.Settings();
PairCalibrationSchedule schedule =
PairCalibrationSchedule.Precompute(data.CloseA, data.CloseB, settings);
PairBacktestReport report = PairBacktest.Run(
$"{pair[0]}/{pair[1]}", data.BarsA, data.BarsB, local.Parameters(), settings, schedule);
Console.WriteLine(" " + report.Describe());
Console.WriteLine($" cointegrate {report.CointegratedFraction:P0} " +
$"β mediano {report.MedianBeta:F3} " +
$"emivita mediana {report.MedianHalfLife:F0} barre " +
$"op/anno {report.TradesPerYear:F0}");
}
Console.WriteLine();
return 0;
}
/// <summary>
/// Grids the thresholds over the <b>whole</b> history and prints what came out.
/// <para>
/// For generating hypotheses, never for choosing a setting: with no held-out slice,
/// the top of this table is the combination that best fits the noise in this
/// particular file. Anything found here has to survive <c>sweep</c> and then
/// <c>confirm</c> before it means anything.
/// </para>
/// </summary>
private static int CommandExplore(Options o)
{
PairData data = PairData.Load(o);
PairBacktestSettings settings = o.Settings();
Combo[] grid = BuildGrid(o);
Console.WriteLine();
Console.WriteLine($" {o.SymbolA} / {o.SymbolB} {data.Count} barre {o.TimeFrameText} " +
$"{data.From:yyyy-MM-dd} -> {data.To:yyyy-MM-dd}");
Console.WriteLine($" {grid.Length} combinazioni sull'intero periodo — ESPLORAZIONE, non selezione");
Console.WriteLine();
PairCalibrationSchedule schedule =
PairCalibrationSchedule.Precompute(data.CloseA, data.CloseB, settings);
Console.WriteLine($" ricalibrazioni: {schedule.Count}, di cui cointegrate {schedule.CointegratedFraction:P0}");
Console.WriteLine();
PairBacktestReport[] reports = new PairBacktestReport[grid.Length];
Parallel.For(0, grid.Length, i =>
reports[i] = PairBacktest.Run(
"x", data.BarsA, data.BarsB, grid[i].ToParameters(o), settings, schedule));
var rows = grid
.Select((c, i) => (Combo: c, Report: reports[i]))
.Where(static r => r.Report.Trades.Count > 0)
.OrderByDescending(static r => r.Report.NetReturn)
.ToArray();
int positive = rows.Count(static r => r.Report.NetReturn > 0);
int grossPositive = rows.Count(static r => r.Report.GrossReturn > 0);
Console.WriteLine($" combinazioni con esito positivo: {positive} su {rows.Length} " +
$"(al lordo delle commissioni: {grossPositive})");
Console.WriteLine();
Console.WriteLine(" entry exit stop win maxbar maxP | netto lordo Calmar Sharpe DD op mercato");
Console.WriteLine(" " + new string('-', 107));
foreach ((Combo c, PairBacktestReport r) in rows.Take(25))
{
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" {c.EntryZ,5:F2} {c.ExitZ,5:F2} {c.StopZ,5:F2} {c.Window,4} {c.MaxBars,7} {c.MaxP,5:F2} | " +
$"{r.NetReturn,9:P2} {r.GrossReturn,9:P2} {r.Calmar,8:F2} {r.SharpeRatio,7:F2} " +
$"{r.MaxDrawdown,6:P1} {r.Trades.Count,6} {r.TimeInMarket,8:P1}"));
}
Console.WriteLine();
return 0;
}
/// <summary>
/// Sweeps the thresholds across <b>every</b> pair at once and ranks them on the
/// combined validation result.
/// <para>
/// This is the command that chooses the shipped defaults, and it works this way for
/// a reason. One pair produces a few dozen trades in six years — far too few to tell
/// a real edge from a lucky one, and a grid ranked on that many observations is
/// fitting noise almost by construction. A parameter set that has to work on several
/// independent pairs at the same time has a much harder problem to accidentally
/// solve, and the defaults are shared by the whole basket anyway, so this is also the
/// question that actually needs answering.
/// </para>
/// <para>
/// Pairs are loaded one at a time and released: holding six pairs of six-year
/// minute-derived series at once costs more memory than the machine running this
/// usefully has.
/// </para>
/// </summary>
private static int CommandBasket(Options o)
{
string[][] candidates =
[
["ETHUSDT", "BTCUSDT"],
["SOLUSDT", "AVAXUSDT"],
["SOLUSDT", "ETHUSDT"],
["AVAXUSDT", "ETHUSDT"],
["SOLUSDT", "BTCUSDT"],
["AVAXUSDT", "BTCUSDT"],
];
string[][] basket = [.. candidates.Where(p =>
File.Exists(Path.Combine(o.DataDirectory, p[0] + ".csv")) &&
File.Exists(Path.Combine(o.DataDirectory, p[1] + ".csv")))];
if (basket.Length == 0)
{
Console.Error.WriteLine("nessuna coppia utilizzabile nella cartella dati");
return 1;
}
Combo[] grid = BuildGrid(o);
PairBacktestSettings settings = o.Settings();
Console.WriteLine();
Console.WriteLine($" {basket.Length} coppie x {grid.Length} combinazioni, timeframe {o.TimeFrameText}");
Console.WriteLine($" costo per cambio di posizione {settings.RoundCost:P4}");
Console.WriteLine();
// [pair][combo] for each slice.
PairBacktestReport[][] train = new PairBacktestReport[basket.Length][];
PairBacktestReport[][] validate = new PairBacktestReport[basket.Length][];
PairBacktestReport[][] confirm = new PairBacktestReport[basket.Length][];
for (int p = 0; p < basket.Length; p++)
{
Options local = o with { SymbolA = basket[p][0], SymbolB = basket[p][1] };
PairData data = PairData.Load(local);
(Slice tr, Slice va, Slice co) = data.Split(o.SplitWeights, settings.CalibrationBars);
Console.WriteLine($" {local.SymbolA}/{local.SymbolB,-10} " +
$"taratura {tr.Count,7:N0} verifica {va.Count,7:N0} conferma {co.Count,7:N0}");
train[p] = RunGrid(grid, o, tr, settings);
validate[p] = RunGrid(grid, o, va, settings);
confirm[p] = RunGrid(grid, o, co, settings);
}
Console.WriteLine();
List<(Combo Combo, Aggregate Train, Aggregate Validate, Aggregate Confirm)> rows = [];
for (int c = 0; c < grid.Length; c++)
{
rows.Add((
grid[c],
Aggregate.Of(train, c),
Aggregate.Of(validate, c),
Aggregate.Of(confirm, c)));
}
var ranked = rows
.Where(static r => r.Train.Trades >= 40 && r.Validate.Trades >= 20)
.Where(static r => r.Train.MeanNet > 0)
.Where(static r => r.Train.PairsPositive >= 2)
.OrderByDescending(static r => r.Validate.MeanCalmar)
.ToList();
Console.WriteLine($" combinazioni che superano i filtri di taratura: {ranked.Count} su {grid.Length}");
Console.WriteLine();
if (ranked.Count == 0)
{
Console.WriteLine(" Nessuna combinazione regge in taratura su almeno due coppie.");
Console.WriteLine(" Non c'e' niente da scegliere: la strategia non ha un margine su questi dati.");
Console.WriteLine();
return 0;
}
Console.WriteLine(" entry exit stop win maxbar maxP | TARATURA net Calmar coppie+ |" +
" VERIFICA net Calmar coppie+ op");
Console.WriteLine(" " + new string('-', 112));
foreach ((Combo c, Aggregate tr, Aggregate va, Aggregate _) in ranked.Take(20))
{
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" {c.EntryZ,5:F2} {c.ExitZ,5:F2} {c.StopZ,5:F2} {c.Window,4} {c.MaxBars,7} {c.MaxP,5:F2} | " +
$"{tr.MeanNet,12:P2} {tr.MeanCalmar,7:F2} {tr.PairsPositive,7} | " +
$"{va.MeanNet,12:P2} {va.MeanCalmar,7:F2} {va.PairsPositive,7} {va.Trades,5}"));
}
Console.WriteLine();
Console.WriteLine(" -- CONFERMA sulla terza fetta, mai usata per scegliere --");
Console.WriteLine();
Console.WriteLine(" entry exit stop win maxbar maxP | CONFERMA net Calmar coppie+ op");
Console.WriteLine(" " + new string('-', 78));
foreach ((Combo c, Aggregate _, Aggregate _, Aggregate co) in ranked.Take(10))
{
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" {c.EntryZ,5:F2} {c.ExitZ,5:F2} {c.StopZ,5:F2} {c.Window,4} {c.MaxBars,7} {c.MaxP,5:F2} | " +
$"{co.MeanNet,12:P2} {co.MeanCalmar,7:F2} {co.PairsPositive,7} {co.Trades,5}"));
}
Console.WriteLine();
int survived = ranked.Take(10).Count(static r => r.Confirm.MeanNet > 0);
Console.WriteLine($" Dei 10 migliori in verifica, {survived} restano positivi in conferma.");
Console.WriteLine();
// Per-pair detail for the winner, which is what tells you whether one pair is
// carrying the whole result.
Combo best = ranked[0].Combo;
Console.WriteLine($" Dettaglio per coppia del primo classificato " +
$"(entry {best.EntryZ:F2}, exit {best.ExitZ:F2}, stop {best.StopZ:F2}, " +
$"window {best.Window}, maxP {best.MaxP:F2}):");
Console.WriteLine();
int bestIndex = Array.IndexOf(grid, best);
for (int p = 0; p < basket.Length; p++)
{
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" {basket[p][0]}/{basket[p][1],-10} " +
$"taratura {train[p][bestIndex].NetReturn,9:P2} ({train[p][bestIndex].Trades.Count,3} op) " +
$"verifica {validate[p][bestIndex].NetReturn,9:P2} ({validate[p][bestIndex].Trades.Count,3} op) " +
$"conferma {confirm[p][bestIndex].NetReturn,9:P2} ({confirm[p][bestIndex].Trades.Count,3} op)"));
}
Console.WriteLine();
return 0;
}
private static PairBacktestReport[] RunGrid(
Combo[] grid, Options o, Slice slice, PairBacktestSettings settings)
{
PairCalibrationSchedule schedule = Schedule(slice, settings);
PairBacktestReport[] reports = new PairBacktestReport[grid.Length];
Parallel.For(0, grid.Length, i =>
reports[i] = PairBacktest.Run(
slice.Label, slice.BarsA, slice.BarsB, grid[i].ToParameters(o), settings, schedule));
return reports;
}
/// <summary>One combination's result averaged across the whole basket.</summary>
private readonly record struct Aggregate(double MeanNet, double MeanCalmar, int Trades, int PairsPositive)
{
public static Aggregate Of(PairBacktestReport[][] byPair, int combo)
{
double net = 0;
double calmar = 0;
int trades = 0;
int positive = 0;
foreach (PairBacktestReport[] reports in byPair)
{
PairBacktestReport r = reports[combo];
net += r.NetReturn;
calmar += r.Calmar;
trades += r.Trades.Count;
if (r.NetReturn > 0)
{
positive++;
}
}
int n = Math.Max(1, byPair.Length);
return new Aggregate(net / n, calmar / n, trades, positive);
}
}
private static int CommandSweep(Options o)
{
PairData data = PairData.Load(o);
PairBacktestSettings baseline = o.Settings();
(Slice train, Slice validate, Slice confirm) = data.Split(o.SplitWeights, baseline.CalibrationBars);
Console.WriteLine();
Console.WriteLine($" {o.SymbolA} / {o.SymbolB} {data.Count} barre {o.TimeFrameText}");
Console.WriteLine($" taratura {train.Describe()}");
Console.WriteLine($" verifica {validate.Describe()}");
Console.WriteLine($" conferma {confirm.Describe()} (non usata per scegliere)");
Console.WriteLine();
Combo[] grid = BuildGrid(o);
Console.WriteLine($" {grid.Length} combinazioni…");
// One schedule per slice, shared by every combination: the cointegration fit does
// not depend on the thresholds being swept.
PairCalibrationSchedule trainSchedule = Schedule(train, baseline);
PairCalibrationSchedule validateSchedule = Schedule(validate, baseline);
Result[] results = new Result[grid.Length];
Parallel.For(0, grid.Length, i =>
{
Combo c = grid[i];
StrategyParameters p = c.ToParameters(o);
PairBacktestReport onTrain = PairBacktest.Run(
"train", train.BarsA, train.BarsB, p, baseline, trainSchedule);
PairBacktestReport onValidate = PairBacktest.Run(
"validate", validate.BarsA, validate.BarsB, c.ToParameters(o), baseline, validateSchedule);
results[i] = new Result(c, onTrain, onValidate);
});
// Ranked on the validation slice, and only among combinations that also worked on
// the training slice. A setting that loses money where it was fitted and makes it
// where it was checked has told you about the checking data, not about itself.
Result[] ranked = [.. results
.Where(static r => r.Train.Trades.Count >= 20 && r.Validate.Trades.Count >= 20)
.Where(static r => r.Train.NetReturn > 0)
.OrderByDescending(static r => r.Validate.Calmar)];
if (ranked.Length == 0)
{
Console.WriteLine();
Console.WriteLine(" Nessuna combinazione ha prodotto abbastanza operazioni in entrambe le fette,");
Console.WriteLine(" oppure nessuna è stata profittevole in taratura. Non c'è niente da scegliere.");
Console.WriteLine();
return 0;
}
Console.WriteLine();
Console.WriteLine(" entry exit stop win maxbar maxP | " +
"TARATURA netto Calmar | VERIFICA netto Calmar Sharpe DD op");
Console.WriteLine(" " + new string('-', 115));
foreach (Result r in ranked.Take(20))
{
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" {r.Combo.EntryZ,5:F2} {r.Combo.ExitZ,5:F2} {r.Combo.StopZ,5:F2} " +
$"{r.Combo.Window,4} {r.Combo.MaxBars,7} {r.Combo.MaxP,5:F2} | " +
$"{r.Train.NetReturn,15:P2} {r.Train.Calmar,8:F2} | " +
$"{r.Validate.NetReturn,10:P2} {r.Validate.Calmar,8:F2} " +
$"{r.Validate.SharpeRatio,7:F2} {r.Validate.MaxDrawdown,6:P1} {r.Validate.Trades.Count,5}"));
}
Console.WriteLine();
Console.WriteLine(" Per confermare il vincitore sulla terza fetta:");
Combo best = ranked[0].Combo;
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" backtest confirm --a {o.SymbolA} --b {o.SymbolB} --data \"{o.DataDirectory}\" " +
$"--tf {o.TimeFrameText} --entry {best.EntryZ:F2} --exit {best.ExitZ:F2} " +
$"--stop {best.StopZ:F2} --window {best.Window} --maxbars {best.MaxBars} " +
$"--maxp {best.MaxP:F2}"));
Console.WriteLine();
return 0;
}
private static int CommandConfirm(Options o)
{
PairData data = PairData.Load(o);
PairBacktestSettings settings = o.Settings();
(Slice train, Slice validate, Slice confirm) = data.Split(o.SplitWeights, settings.CalibrationBars);
Console.WriteLine();
Console.WriteLine($" {o.SymbolA} / {o.SymbolB} " +
$"entry {o.EntryZ:F2} exit {o.ExitZ:F2} stop {o.StopZ:F2} " +
$"window {o.Window} maxbars {o.MaxBars}");
Console.WriteLine();
foreach ((string label, Slice slice) in new[]
{
("taratura", train),
("verifica", validate),
("CONFERMA", confirm),
})
{
PairCalibrationSchedule schedule = Schedule(slice, settings);
PairBacktestReport report = PairBacktest.Run(
label, slice.BarsA, slice.BarsB, o.Parameters(), settings, schedule);
Console.WriteLine($" {label,-9} {slice.Describe()}");
Console.WriteLine(" " + report.Describe());
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" op/anno {report.TradesPerYear,6:F0} " +
$"barre medie in posizione {report.AverageBarsHeld,6:F0} " +
$"commissioni pagate {report.FeesPaid,7:P2} " +
$"cointegrate {report.CointegratedFraction,5:P0}"));
Console.WriteLine();
}
return 0;
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static PairCalibrationSchedule Schedule(Slice slice, PairBacktestSettings settings) =>
PairCalibrationSchedule.Precompute(slice.CloseA, slice.CloseB, settings);
private static Combo[] BuildGrid(Options o)
{
double[] entries = o.GridEntry ?? [1.5, 1.75, 2.0, 2.25, 2.5, 3.0];
double[] exits = o.GridExit ?? [0.0, 0.2, 0.5, 0.75];
double[] stops = o.GridStop ?? [3.0, 3.5, 4.0, 5.0];
int[] windows = o.GridWindow ?? [60, 100, 150, 200];
int[] maxBars = o.GridMaxBars ?? [0, 288, 576];
double[] maxPs = o.GridMaxP ?? [o.MaxPValue];
List<Combo> combos = [];
foreach (double entry in entries)
{
foreach (double exit in exits)
{
if (exit >= entry)
{
continue;
}
foreach (double stop in stops)
{
if (stop <= entry)
{
continue;
}
foreach (int window in windows)
{
foreach (int bars in maxBars)
{
foreach (double maxP in maxPs)
{
combos.Add(new Combo(entry, exit, stop, window, bars, maxP));
}
}
}
}
}
}
return [.. combos];
}
private static void Report(PairBacktestReport r, PairBacktestSettings settings)
{
Console.WriteLine($" {new string('=', 74)}");
Console.WriteLine($" {r.PairName} {r.FromUtc:yyyy-MM-dd} → {r.ToUtc:yyyy-MM-dd} ({r.Years:F2} anni)");
Console.WriteLine($" {new string('=', 74)}");
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Barre valutate : {r.Bars:N0}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Operazioni : {r.Trades.Count:N0} ({r.TradesPerYear:F0}/anno)"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Rendimento netto : {r.NetReturn:P2} sul controvalore impegnato"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Rendimento lordo : {r.GrossReturn:P2}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Commissioni pagate : {r.FeesPaid:P2}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" CAGR : {r.Cagr:P2}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Sharpe annualizzato : {r.SharpeRatio:F2}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Max drawdown : {r.MaxDrawdown:P2}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Calmar : {r.Calmar:F2}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Operazioni vinte : {r.WinRate:P1}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Barre medie in posizione: {r.AverageBarsHeld:F0}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Tempo in mercato : {r.TimeInMarket:P1}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Ricalibrazioni passate : {r.CointegratedFraction:P0}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" β mediano : {r.MedianBeta:F4}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" Emivita mediana : {r.MedianHalfLife:F0} barre"));
if (settings.FitOnce)
{
Console.WriteLine();
Console.WriteLine(" ATTENZIONE: --fit-once usa un beta calcolato su prezzi futuri.");
Console.WriteLine(" Questo numero non è realizzabile: serve solo a misurare il bias.");
}
Console.WriteLine($" {new string('=', 74)}");
Console.WriteLine();
ExitBreakdown(r);
}
private static void ExitBreakdown(PairBacktestReport r)
{
if (r.Trades.Count == 0)
{
return;
}
Dictionary<string, (int Count, double Sum)> byReason = new(StringComparer.Ordinal);
foreach (PairTrade t in r.Trades)
{
string key = Classify(t.ExitReason);
(int count, double sum) = byReason.TryGetValue(key, out (int, double) existing) ? existing : (0, 0.0);
byReason[key] = (count + 1, sum + t.ReturnPct);
}
Console.WriteLine(" Come si sono chiuse:");
foreach ((string reason, (int count, double sum)) in byReason.OrderByDescending(static k => k.Value.Count))
{
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" {reason,-22} {count,5} ({count / (double)r.Trades.Count,6:P1}) " +
$"resa media {sum / count,8:P3}"));
}
Console.WriteLine();
}
private static string Classify(string reason) =>
reason.Contains("stop statistico", StringComparison.Ordinal) ? "stop statistico"
: reason.Contains("rientrato", StringComparison.Ordinal) ? "rientro (profitto)"
: reason.Contains("attraversato", StringComparison.Ordinal) ? "attraversamento"
: reason.Contains("temporale", StringComparison.Ordinal) ? "stop temporale"
: reason.Contains("ricalibrazione", StringComparison.Ordinal) ? "cointegrazione persa"
: "altro";
private readonly record struct Combo(
double EntryZ, double ExitZ, double StopZ, int Window, int MaxBars, double MaxP)
{
public StrategyParameters ToParameters(Options o) =>
new StrategyParameters()
.Set("entryZ", EntryZ)
.Set("exitZ", ExitZ)
.Set("stopZ", StopZ)
.Set("zWindow", Window)
.Set("maxBarsInTrade", MaxBars)
.Set("maxPValue", MaxP)
.Set("minHalfLife", o.MinHalfLife)
.Set("maxHalfLife", o.MaxHalfLife)
.Set("requireCointegration", o.RequireCointegration ? 1 : 0);
}
private sealed record Result(Combo Combo, PairBacktestReport Train, PairBacktestReport Validate);
}