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>
701 lines
31 KiB
C#
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);
|
|
}
|