using System.Globalization; using System.Text; using Encelado.Core.Market; using Encelado.Core.Risk; using Encelado.Core.Strategies; namespace Encelado.Core.Backtest; /// One symbol and the strategy instance that will trade it during a replay. public sealed record BacktestSymbol(string Symbol, IStrategy Strategy); /// /// 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. /// public sealed record BacktestSettings { public RiskLimits Risk { get; init; } = new(); public double StartingEquity { get; init; } = 10_000; /// Price concession paid on every fill, in basis points. public double SlippageBps { get; init; } = 8; /// /// 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. /// 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) { /// Profit after slippage and broker fees. This is the only number that matters. 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 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; /// Compound annual growth rate. Meaningless for very short windows. public double Cagr { get { if (StartEquity <= 0 || EndEquity <= 0 || Years < 0.08) { return 0; } return Math.Pow(EndEquity / StartEquity, 1.0 / Years) - 1; } } /// Annualised return divided by max drawdown — the ratio that decides if a book is fundable. 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 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 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"; } }