Files
Encelado/Encelado/tests/Encelado.Tests/BacktestTests.cs
T

332 lines
11 KiB
C#

using Encelado.Core.Backtest;
using Encelado.Core.Market;
using Encelado.Core.Risk;
using Encelado.Core.Strategies;
namespace Encelado.Tests;
public class ReplayerTests
{
private static readonly DateTime Start = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
// Short periods so a few hundred synthetic bars produce several round trips. The
// shipped values (100 bars, 2% band) would barely trade inside a test fixture.
private static StrategyParameters Params() => new StrategyParameters()
.Set("period", 12)
.Set("band", 0.01)
.Set("stopPct", 0.35)
.Set("cvdThreshold", 0)
.Set("volPeriod", 12)
.Set("atrPeriod", 8)
.Set("barsPerYear", 365);
private static BacktestSettings Settings(double feeBps = 0) => new()
{
// Mirrors config/encelado.json where it matters: full stake, and a stop-distance
// ceiling wide enough to accept the strategy's deliberately far backstop. At the
// default 15% every entry would be refused as InvalidStop and the replayer would
// report a clean run with zero trades.
Risk = new RiskLimits
{
StakePct = 1.0,
MaxRiskPerTradePct = 0.01,
MaxPositionNotionalPct = 1.0,
MaxGrossExposurePct = 1.0,
MaxOpenPositions = 1,
MaxTradesPerDay = 1000,
MaxTradesPerSymbolPerDay = 1000,
MinSecondsBetweenEntries = 0,
MaxRelativeSpread = 0,
MinOrderNotional = 1,
DefaultStopPct = 0.35,
MaxStopDistancePct = 0.60,
},
StartingEquity = 100_000,
SlippageBps = 5,
FeeBps = feeBps,
AllowFractional = true,
};
/// <summary>A saw-tooth: long enough legs in both directions to trigger crossings.</summary>
private static List<Bar> SawTooth(int cycles, int legLength, double amplitude)
{
List<Bar> bars = [];
double price = 100;
int index = 0;
for (int c = 0; c < cycles; c++)
{
for (int i = 0; i < legLength; i++)
{
price += amplitude;
bars.Add(Make(index++, price));
}
for (int i = 0; i < legLength; i++)
{
price -= amplitude;
bars.Add(Make(index++, price));
}
}
return bars;
static Bar Make(int i, double close) =>
new(Start.AddMinutes(i), close, close + 0.4, close - 0.4, close, 10_000, close, 25);
}
private static BacktestReport Run(
IReadOnlyList<Bar> bars, BacktestSettings settings, StrategyParameters? p = null) =>
new Replayer(settings).Run(
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", p ?? Params()))],
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = bars });
[Fact]
public void ProducesTradesAndACoherentEquityCurve()
{
List<Bar> bars = SawTooth(6, 45, 1.2);
BacktestReport report = Run(bars, Settings());
Assert.True(report.Trades.Count > 0, "the saw-tooth should trigger at least one round trip");
Assert.Equal(bars.Count, report.BarsProcessed);
// Every closed trade must be accounted for exactly once in the final equity.
Assert.Equal(report.StartEquity + report.Trades.Sum(t => t.Pnl), report.EndEquity, 6);
Assert.Equal(report.Trades.Count, report.Wins + report.Losses);
Assert.InRange(report.MaxDrawdownPct, 0, 1);
}
[Fact]
public void EntriesFillOnTheNextBarNotTheSignalBar()
{
List<Bar> bars = SawTooth(6, 45, 1.2);
BacktestReport report = Run(bars, Settings());
foreach (ClosedTrade trade in report.Trades)
{
// The fill must match some bar's open plus slippage, never a close.
Assert.Contains(bars, b => Math.Abs((b.Open * 1.0005) - trade.EntryPrice) < 1e-6);
Assert.True(trade.ExitUtc >= trade.EntryUtc);
Assert.True(trade.Quantity > 0);
}
}
[Fact]
public void EverythingIsLiquidatedAtTheEndOfTheRun()
{
// Down then up, ending firmly above the average, so the run finishes holding a
// position that the replayer has to liquidate.
List<Bar> bars = [];
double price = 200;
int index = 0;
for (int i = 0; i < 60; i++)
{
price *= 0.995;
bars.Add(Make(index++, price));
}
for (int i = 0; i < 150; i++)
{
price *= 1.006;
bars.Add(Make(index++, price));
}
BacktestReport report = Run(bars, Settings(), Params());
Assert.Contains(report.Trades, t => t.ExitReason == "end of backtest");
Assert.Equal(report.StartEquity + report.Trades.Sum(t => t.Pnl), report.EndEquity, 6);
static Bar Make(int i, double close) =>
new(Start.AddMinutes(i), close, close + 0.3, close - 0.3, close, 10_000, close, 20);
}
[Fact]
public void FeesAreChargedOnBothSidesAndReduceTheResult()
{
List<Bar> bars = SawTooth(6, 45, 1.2);
BacktestReport free = Run(bars, Settings(feeBps: 0));
BacktestReport charged = Run(bars, Settings(feeBps: 25));
Assert.Equal(0, free.TotalFees, 6);
Assert.True(charged.TotalFees > 0, "a 25 bps fee must actually cost something");
Assert.True(charged.EndEquity < free.EndEquity, "fees must reduce the final equity");
// Two fills per round trip, so the total is roughly 2 x fee x notional.
foreach (ClosedTrade trade in charged.Trades)
{
Assert.True(trade.Fees > 0);
Assert.Equal(trade.GrossPnl - trade.Fees, trade.Pnl, 9);
}
}
[Fact]
public void ThrowsWhenNoSymbolHasHistory()
{
Replayer replayer = new(Settings());
Assert.Throws<InvalidOperationException>(() =>
replayer.Run(
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
new Dictionary<string, IReadOnlyList<Bar>>()));
}
[Fact]
public void ReportsProgressAndHonoursCancellation()
{
List<double> progress = [];
Replayer replayer = new(Settings()) { OnProgress = progress.Add };
replayer.Run(
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = SawTooth(6, 45, 1.2) });
Assert.NotEmpty(progress);
Assert.Equal(1.0, progress[^1], 6);
using CancellationTokenSource cts = new();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() =>
new Replayer(Settings()).Run(
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = SawTooth(6, 45, 1.2) },
cts.Token));
}
[Fact]
public void RenderProducesASummaryWithoutThrowingOnAnEmptyRun()
{
BacktestReport empty = new(100_000, 100_000, 0, [], 0, Start, Start.AddDays(1), 0);
Assert.Equal(0, empty.WinRate);
Assert.Equal(0, empty.ProfitFactor);
Assert.Contains("trades 0", empty.Render(), StringComparison.Ordinal);
}
}
public class CsvBarSourceTests : IDisposable
{
private readonly List<string> _files = [];
private string Write(string content)
{
string path = Path.Combine(Path.GetTempPath(), $"encelado-csv-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, content);
_files.Add(path);
return path;
}
public void Dispose()
{
foreach (string f in _files)
{
File.Delete(f);
}
}
[Fact]
public void ReadsBinanceKlinesWithMillisecondTimestamps()
{
// 1502942400000 = 2017-08-17 04:00:00 UTC
string path = Write(
"""
timestamp,open,high,low,close,volume,close_timestamp,quote_asset_volume,number_of_trades
1502942400000,4261.48,4280.56,4261.48,4261.48,2,1502943299999,9333.62,9
1502943300000,4261.48,4270.41,4261.32,4261.45,9,1502944199999,38891.1,40
""");
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
Assert.Equal(2, bars.Count);
Assert.Equal(new DateTime(2017, 8, 17, 4, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
Assert.Equal(4261.48, bars[0].Open);
Assert.Equal(4280.56, bars[0].High);
Assert.Equal(4261.48, bars[0].Low);
Assert.Equal(4261.48, bars[0].Close);
Assert.Equal(2, bars[0].Volume);
Assert.Equal(9, bars[0].TradeCount);
}
[Fact]
public void ReadsIsoDatesAndSecondEpochsToo()
{
string iso = Write(
"""
date,open,high,low,close,volume
2024-05-17T13:00:00Z,100,110,95,105,1000
2024-05-17T14:00:00Z,105,115,100,112,1200
""");
IReadOnlyList<Bar> bars = CsvBarSource.Load(iso);
Assert.Equal(new DateTime(2024, 5, 17, 13, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
Assert.Equal(105, bars[0].Close);
string seconds = Write(
"""
time,open,high,low,close
1715950800,100,110,95,105
""");
Assert.Equal(
DateTimeOffset.FromUnixTimeSeconds(1715950800).UtcDateTime,
CsvBarSource.Load(seconds)[0].TimeUtc);
}
[Fact]
public void SkipsMalformedRowsInsteadOfFailing()
{
string path = Write(
"""
timestamp,open,high,low,close,volume
1502942400000,4261.48,4280.56,4261.48,4261.48,2
not-a-number,1,2,3,4,5
1502943300000,abc,4270.41,4261.32,4261.45,9
1502944200000,0,0,0,0,0
1502945100000,100,110,95,105,7
""");
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
Assert.Equal(2, bars.Count);
Assert.Equal(105, bars[1].Close);
}
[Fact]
public void SortsRowsThatArriveOutOfOrder()
{
string path = Write(
"""
timestamp,open,high,low,close
1502943300000,2,2,2,2
1502942400000,1,1,1,1
""");
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
Assert.Equal(2, bars.Count);
Assert.True(bars[0].TimeUtc < bars[1].TimeUtc);
Assert.Equal(1, bars[0].Close);
}
[Fact]
public void RejectsAHeaderWithoutTheRequiredColumns()
{
string path = Write("alpha,beta\n1,2");
Assert.Throws<InvalidDataException>(() => CsvBarSource.Load(path));
}
[Fact]
public void RejectsAFileWithNoUsableRows()
{
string path = Write("timestamp,open,high,low,close\nx,x,x,x,x");
Assert.Throws<InvalidDataException>(() => CsvBarSource.Load(path));
}
[Fact]
public void ReportsAMissingFileClearly() =>
Assert.Throws<FileNotFoundException>(() =>
CsvBarSource.Load(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.csv")));
}