161 lines
6.1 KiB
C#
161 lines
6.1 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using Encelado.Core.Market;
|
|
using Encelado.Core.Risk;
|
|
using Encelado.Core.Strategies;
|
|
|
|
namespace Encelado.Core.Backtest;
|
|
|
|
/// <summary>One symbol and the strategy instance that will trade it during a replay.</summary>
|
|
public sealed record BacktestSymbol(string Symbol, IStrategy Strategy);
|
|
|
|
/// <summary>
|
|
/// Everything that makes a replay realistic. The defaults model Alpaca crypto: a
|
|
/// marketable-limit slippage plus a per-fill fee, both charged in both directions.
|
|
/// </summary>
|
|
public sealed record BacktestSettings
|
|
{
|
|
public RiskLimits Risk { get; init; } = new();
|
|
|
|
public double StartingEquity { get; init; } = 10_000;
|
|
|
|
/// <summary>Price concession paid on every fill, in basis points.</summary>
|
|
public double SlippageBps { get; init; } = 8;
|
|
|
|
/// <summary>
|
|
/// Broker fee per fill, in basis points of notional. Alpaca crypto taker fees start
|
|
/// around 25 bps, so a round trip costs roughly 50 bps. Leaving this at zero is the
|
|
/// single easiest way to produce a backtest that cannot be reproduced live.
|
|
/// </summary>
|
|
public double FeeBps { get; init; } = 25;
|
|
|
|
public bool AllowFractional { get; init; } = true;
|
|
}
|
|
|
|
public sealed record ClosedTrade(
|
|
string Symbol,
|
|
Side Side,
|
|
DateTime EntryUtc,
|
|
DateTime ExitUtc,
|
|
double Quantity,
|
|
double EntryPrice,
|
|
double ExitPrice,
|
|
double GrossPnl,
|
|
double Fees,
|
|
string ExitReason)
|
|
{
|
|
/// <summary>Profit after slippage and broker fees. This is the only number that matters.</summary>
|
|
public double Pnl => GrossPnl - Fees;
|
|
|
|
public bool IsWin => Pnl > 0;
|
|
|
|
public TimeSpan Holding => ExitUtc - EntryUtc;
|
|
|
|
public double ReturnPct => EntryPrice > 0 && Quantity > 0 ? Pnl / (EntryPrice * Quantity) : 0;
|
|
}
|
|
|
|
public sealed record BacktestReport(
|
|
double StartEquity,
|
|
double EndEquity,
|
|
double MaxDrawdownPct,
|
|
IReadOnlyList<ClosedTrade> Trades,
|
|
int BarsProcessed,
|
|
DateTime FromUtc,
|
|
DateTime ToUtc,
|
|
double TotalFees)
|
|
{
|
|
public static readonly BacktestReport Empty =
|
|
new(0, 0, 0, [], 0, DateTime.MinValue, DateTime.MinValue, 0);
|
|
|
|
public double NetPnl => EndEquity - StartEquity;
|
|
|
|
public double ReturnPct => StartEquity > 0 ? NetPnl / StartEquity : 0;
|
|
|
|
public int Wins => Trades.Count(t => t.IsWin);
|
|
|
|
public int Losses => Trades.Count - Wins;
|
|
|
|
public double WinRate => Trades.Count > 0 ? Wins / (double)Trades.Count : 0;
|
|
|
|
public double GrossProfit => Trades.Where(t => t.Pnl > 0).Sum(t => t.Pnl);
|
|
|
|
public double GrossLoss => -Trades.Where(t => t.Pnl < 0).Sum(t => t.Pnl);
|
|
|
|
public double ProfitFactor =>
|
|
GrossLoss > 0 ? GrossProfit / GrossLoss : GrossProfit > 0 ? double.PositiveInfinity : 0;
|
|
|
|
public double AverageWin => Wins > 0 ? GrossProfit / Wins : 0;
|
|
|
|
public double AverageLoss => Losses > 0 ? GrossLoss / Losses : 0;
|
|
|
|
public double Expectancy => Trades.Count > 0 ? NetPnl / Trades.Count : 0;
|
|
|
|
public double Years => (ToUtc - FromUtc).TotalDays / 365.25;
|
|
|
|
/// <summary>Compound annual growth rate. Meaningless for very short windows.</summary>
|
|
public double Cagr
|
|
{
|
|
get
|
|
{
|
|
if (StartEquity <= 0 || EndEquity <= 0 || Years < 0.08)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return Math.Pow(EndEquity / StartEquity, 1.0 / Years) - 1;
|
|
}
|
|
}
|
|
|
|
/// <summary>Annualised return divided by max drawdown — the ratio that decides if a book is fundable.</summary>
|
|
public double CalmarRatio => MaxDrawdownPct > 0 ? Cagr / MaxDrawdownPct : 0;
|
|
|
|
public TimeSpan AverageHolding => Trades.Count == 0
|
|
? TimeSpan.Zero
|
|
: TimeSpan.FromMinutes(Trades.Average(t => t.Holding.TotalMinutes));
|
|
|
|
public string Render()
|
|
{
|
|
StringBuilder sb = new(1400);
|
|
CultureInfo ci = CultureInfo.InvariantCulture;
|
|
|
|
sb.AppendLine(ci, $"period {FromUtc:yyyy-MM-dd} .. {ToUtc:yyyy-MM-dd} ({Years:F2} years, {BarsProcessed:N0} bars)");
|
|
sb.AppendLine(ci, $"equity {StartEquity:N2} -> {EndEquity:N2} ({ReturnPct:P2})");
|
|
sb.AppendLine(ci, $"CAGR {Cagr:P2}");
|
|
sb.AppendLine(ci, $"max drawdown {MaxDrawdownPct:P2} Calmar {CalmarRatio:F2}");
|
|
sb.AppendLine(ci, $"net P&L {NetPnl:N2} fees paid {TotalFees:N2}");
|
|
sb.AppendLine(ci, $"trades {Trades.Count} (wins {Wins} / losses {Losses}, win rate {WinRate:P1})");
|
|
sb.AppendLine(ci, $"profit factor {(double.IsInfinity(ProfitFactor) ? "inf" : ProfitFactor.ToString("F2", ci))}");
|
|
sb.AppendLine(ci, $"avg win / loss {AverageWin:N2} / {AverageLoss:N2} expectancy {Expectancy:N2}/trade");
|
|
sb.AppendLine(ci, $"avg holding {AverageHolding.TotalHours:F1} h");
|
|
|
|
if (Trades.Count > 0)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine("per symbol:");
|
|
foreach (IGrouping<string, ClosedTrade> g in Trades.GroupBy(t => t.Symbol).OrderBy(g => g.Key))
|
|
{
|
|
sb.AppendLine(ci,
|
|
$" {g.Key,-12} trades={g.Count(),4} wins={g.Count(t => t.IsWin),4} pnl={g.Sum(t => t.Pnl),14:N2}");
|
|
}
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine("exit reasons:");
|
|
foreach (IGrouping<string, ClosedTrade> g in Trades.GroupBy(t => Bucket(t.ExitReason)).OrderByDescending(g => g.Count()))
|
|
{
|
|
sb.AppendLine(ci, $" {g.Key,-24} {g.Count(),4} pnl={g.Sum(t => t.Pnl),14:N2}");
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
|
|
// A trailing stop is how a trend trade normally *takes profit*, so lumping it
|
|
// in with the protective stop would make a winning exit look like a loss.
|
|
static string Bucket(string reason) =>
|
|
reason.Contains("trailing stop", StringComparison.OrdinalIgnoreCase) ? "trailing stop"
|
|
: reason.Contains("take profit", StringComparison.OrdinalIgnoreCase) ? "take profit"
|
|
: reason.Contains("stop", StringComparison.OrdinalIgnoreCase) ? "stop loss"
|
|
: reason.Contains("end of backtest", StringComparison.OrdinalIgnoreCase) ? "end of data"
|
|
: "signal exit";
|
|
}
|
|
}
|