332 lines
11 KiB
C#
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")));
|
|
}
|