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";
}
}