feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FinlyticCore.Dtos.Simulation;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticTechnicals.Indicators;
|
||||
using FinlyticTechnicals.Patterns;
|
||||
using FinlyticTechnicals.Strategies;
|
||||
|
||||
namespace FinlyticSimulation.Engine;
|
||||
|
||||
public class HistoricalReplayRunner
|
||||
{
|
||||
private readonly ITechnicalStrategy _strategy;
|
||||
private readonly IEnumerable<IPatternDetector> _patternDetectors;
|
||||
|
||||
public HistoricalReplayRunner(
|
||||
ITechnicalStrategy strategy,
|
||||
IEnumerable<IPatternDetector> patternDetectors)
|
||||
{
|
||||
_strategy = strategy;
|
||||
_patternDetectors = patternDetectors;
|
||||
}
|
||||
|
||||
public BacktestReportDto Run(
|
||||
IReadOnlyList<CandleDto> candles,
|
||||
BacktestRequestDto request,
|
||||
decimal slippagePercent,
|
||||
decimal orderFeeEur,
|
||||
decimal knockOutBufferPercent,
|
||||
decimal defaultTrailingStopPercent)
|
||||
{
|
||||
if (candles == null || candles.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Candles list cannot be empty for backtesting.", nameof(candles));
|
||||
}
|
||||
|
||||
var virtualBroker = new VirtualBacktestBroker(
|
||||
request.StartingCapital,
|
||||
request.RiskPerTradePercent,
|
||||
request.IncludeFeesAndSlippage,
|
||||
request.SimulateKnockOutDerivatives,
|
||||
request.TargetLeverage,
|
||||
slippagePercent,
|
||||
orderFeeEur,
|
||||
knockOutBufferPercent,
|
||||
defaultTrailingStopPercent
|
||||
);
|
||||
|
||||
int warmupIndex = Math.Min(50, candles.Count / 3);
|
||||
if (warmupIndex < 14) warmupIndex = 14;
|
||||
|
||||
if (candles.Count <= warmupIndex)
|
||||
{
|
||||
throw new InvalidOperationException($"Nicht genügend historische Kerzen ({candles.Count}) für den Backtest vorhanden.");
|
||||
}
|
||||
|
||||
for (int i = warmupIndex; i < candles.Count; i++)
|
||||
{
|
||||
var currentCandle = candles[i];
|
||||
|
||||
// 1. ZUERST: Offene Positionen gegen die aktuelle Kerze prüfen (Exits, Stop-Loss, Knock-Out)
|
||||
virtualBroker.UpdateActivePositions(currentCandle);
|
||||
|
||||
// 2. DANN: Kontext isolieren (nur abgeschlossene Kerzen bis i übergeben -> Anti-Lookahead)
|
||||
var slice = candles.Take(i + 1).ToList();
|
||||
var context = CreateContextSlice(request.Isin, request.Symbol, request.Timeframe, slice, currentCandle, request.StrategyParameters);
|
||||
|
||||
// 3. Pattern Detectors auf aktuellem Slice auswerten
|
||||
var activePatterns = new List<PatternResultDto>();
|
||||
foreach (var detector in _patternDetectors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pattern = detector.Evaluate(context);
|
||||
if (pattern != null) activePatterns.Add(pattern);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore transient calculation issues on minimal slices
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. Strategie evaluieren
|
||||
if (_strategy.IsApplicable(context.Regime))
|
||||
{
|
||||
try
|
||||
{
|
||||
var setup = _strategy.Evaluate(context, activePatterns);
|
||||
if (setup != null && virtualBroker.CanOpenPosition())
|
||||
{
|
||||
virtualBroker.OpenPosition(setup, currentCandle);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore strategy eval issues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Am Ende alle verbleibenden Positionen schließen
|
||||
virtualBroker.CloseRemainingPositions(candles[^1]);
|
||||
|
||||
return virtualBroker.BuildReport(request, Guid.NewGuid());
|
||||
}
|
||||
|
||||
private static TechnicalContext CreateContextSlice(
|
||||
string isin,
|
||||
string symbol,
|
||||
string timeframe,
|
||||
IReadOnlyList<CandleDto> slice,
|
||||
CandleDto currentCandle,
|
||||
Dictionary<string, decimal>? strategyParameters)
|
||||
{
|
||||
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(slice, 14);
|
||||
var adx = TechnicalIndicatorsEngine.CalculateAdx(slice, 14);
|
||||
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(slice, 20);
|
||||
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(slice, 50);
|
||||
|
||||
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
|
||||
if (adx.IsTrending)
|
||||
{
|
||||
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
|
||||
}
|
||||
else if (currentAtr > (currentCandle.Close * 0.03m))
|
||||
{
|
||||
regime = MarketRegime.HighVolatilityChoppy;
|
||||
}
|
||||
|
||||
var indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["EMA_20"] = ema20,
|
||||
["EMA_50"] = ema50,
|
||||
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(slice, 200),
|
||||
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(slice, 14),
|
||||
["ATR_14"] = currentAtr,
|
||||
["ADX_14"] = adx.Adx,
|
||||
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(slice)
|
||||
};
|
||||
|
||||
// Multi-timeframe strategies (e.g. SuperTrendMultiTfStrategy, which needs both "15m" and "1h") used to
|
||||
// structurally never fire in a backtest: this dictionary only ever carried the single requested
|
||||
// `timeframe` key, so context.GetCandles("1h") always returned empty when the backtest ran on "15m"
|
||||
// candles. Every known timeframe coarser than the base is now derived by resampling the same slice
|
||||
// (via CandleResampler, shared with the live MultiTimeframeCandleAggregator) so a strategy asking for
|
||||
// any coarser timeframe gets a real, consistently-computed series instead of nothing. A timeframe
|
||||
// FINER than the base cannot be derived (no way to invent sub-bar data, Rules.md §4) and is simply
|
||||
// absent - a strategy needing that will honestly find no candles rather than a fabricated series.
|
||||
var allTimeframes = new Dictionary<string, IReadOnlyList<CandleDto>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[timeframe] = slice
|
||||
};
|
||||
|
||||
if (CandleResampler.KnownTimeframeMinutes.TryGetValue(timeframe, out var baseMinutes))
|
||||
{
|
||||
foreach (var (coarserTimeframe, coarserMinutes) in CandleResampler.CoarserTimeframes(baseMinutes))
|
||||
{
|
||||
allTimeframes[coarserTimeframe] = CandleResampler.Resample(slice, coarserMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
return new TechnicalContext
|
||||
{
|
||||
Isin = isin,
|
||||
Symbol = symbol,
|
||||
Timeframe = timeframe,
|
||||
TimestampUtc = currentCandle.Timestamp,
|
||||
CurrentPrice = currentCandle.Close,
|
||||
CurrentSpread = 0m,
|
||||
IsSpreadVolatile = false,
|
||||
CurrentAtr = currentAtr,
|
||||
Regime = regime,
|
||||
MultiTimeframeCandles = allTimeframes,
|
||||
Indicators = indicators,
|
||||
ParameterOverrides = strategyParameters ?? new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FinlyticCore.Dtos.Simulation;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
|
||||
namespace FinlyticSimulation.Engine;
|
||||
|
||||
internal class VirtualPosition
|
||||
{
|
||||
public Guid PositionId { get; set; } = Guid.NewGuid();
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
public SignalDirection Direction { get; set; }
|
||||
public DateTime EntryTimeUtc { get; set; }
|
||||
public decimal RawEntryPrice { get; set; }
|
||||
public decimal ExecutedEntryPrice { get; set; }
|
||||
public decimal TotalQuantity { get; set; }
|
||||
public decimal RemainingQuantity { get; set; }
|
||||
public decimal InitialStopLoss { get; set; }
|
||||
public decimal CurrentStopLoss { get; set; }
|
||||
public decimal TakeProfit1 { get; set; }
|
||||
public decimal TakeProfit2 { get; set; }
|
||||
public bool Tp1Hit { get; set; }
|
||||
public bool Tp2Hit { get; set; }
|
||||
public bool IsKnockOut { get; set; }
|
||||
public decimal? Barrier { get; set; }
|
||||
public decimal? Leverage { get; set; }
|
||||
public decimal TotalFees { get; set; }
|
||||
public decimal RealizedPnlEur { get; set; }
|
||||
public decimal MaxPriceSeen { get; set; }
|
||||
public decimal MinPriceSeen { get; set; }
|
||||
public ExitPlan ExitPlan { get; set; } = null!;
|
||||
|
||||
/// <summary>ATR at entry, used to honor an <c>AtrMultiplier</c> trailing-stop rule honestly (see <c>VirtualBacktestBroker.UpdateActivePositions</c>).</summary>
|
||||
public decimal EntryAtr { get; set; }
|
||||
}
|
||||
|
||||
public class VirtualBacktestBroker
|
||||
{
|
||||
private readonly decimal _startingCapital;
|
||||
private readonly decimal _riskPerTradePercent;
|
||||
private readonly bool _includeFeesAndSlippage;
|
||||
private readonly bool _simulateKnockOutDerivatives;
|
||||
private readonly decimal? _targetLeverage;
|
||||
|
||||
// Previously hardcoded literals (0.0005m / 1.00m / 0.98-1.02 / flat 3% trail) that silently ignored
|
||||
// SimulationSettingKeys.DefaultSlippagePercent/DefaultOrderFeeEur (dead settings nobody's value ever
|
||||
// reached this broker) and any per-backtest-request tuning. Now real constructor inputs, sourced from
|
||||
// settings by QuantSimulationEngine.RunBacktestAsync (Rules.md §12: no hardcoded values).
|
||||
private readonly decimal _slippagePercent;
|
||||
private readonly decimal _orderFeeEur;
|
||||
private readonly decimal _knockOutBufferPercent;
|
||||
private readonly decimal _defaultTrailingStopPercent;
|
||||
|
||||
private decimal _currentCapital;
|
||||
private decimal _peakCapital;
|
||||
private readonly List<VirtualPosition> _openPositions = new();
|
||||
private readonly List<BacktestTradeDto> _closedTrades = new();
|
||||
private readonly List<EquityPointDto> _equityCurve = new();
|
||||
|
||||
public VirtualBacktestBroker(
|
||||
decimal startingCapital,
|
||||
decimal riskPerTradePercent,
|
||||
bool includeFeesAndSlippage,
|
||||
bool simulateKnockOutDerivatives,
|
||||
decimal? targetLeverage,
|
||||
decimal slippagePercent,
|
||||
decimal orderFeeEur,
|
||||
decimal knockOutBufferPercent,
|
||||
decimal defaultTrailingStopPercent)
|
||||
{
|
||||
_startingCapital = startingCapital > 0 ? startingCapital : 10000m;
|
||||
_currentCapital = _startingCapital;
|
||||
_peakCapital = _startingCapital;
|
||||
_riskPerTradePercent = Math.Clamp(riskPerTradePercent, 0.1m, 10.0m);
|
||||
_includeFeesAndSlippage = includeFeesAndSlippage;
|
||||
_simulateKnockOutDerivatives = simulateKnockOutDerivatives;
|
||||
_targetLeverage = targetLeverage ?? 5.0m;
|
||||
_slippagePercent = slippagePercent / 100m;
|
||||
_orderFeeEur = orderFeeEur;
|
||||
_knockOutBufferPercent = knockOutBufferPercent;
|
||||
_defaultTrailingStopPercent = defaultTrailingStopPercent;
|
||||
}
|
||||
|
||||
public bool CanOpenPosition()
|
||||
{
|
||||
return _openPositions.Count < 3 && _currentCapital > (_startingCapital * 0.1m);
|
||||
}
|
||||
|
||||
public void OpenPosition(StrategyResultDto setup, CandleDto candle)
|
||||
{
|
||||
if (setup.EntryPrice <= 0 || setup.InvalidationPrice <= 0) return;
|
||||
|
||||
decimal unitRisk = Math.Abs(setup.EntryPrice - setup.InvalidationPrice);
|
||||
if (unitRisk <= 0) return;
|
||||
|
||||
decimal riskAmountEur = _currentCapital * (_riskPerTradePercent / 100.0m);
|
||||
decimal quantity = Math.Round(riskAmountEur / unitRisk, 2);
|
||||
if (quantity <= 0) quantity = 1;
|
||||
|
||||
// Apply slippage to entry
|
||||
decimal slippage = _includeFeesAndSlippage ? setup.EntryPrice * _slippagePercent : 0m;
|
||||
decimal executedPrice = setup.Direction == SignalDirection.Buy
|
||||
? setup.EntryPrice + slippage
|
||||
: setup.EntryPrice - slippage;
|
||||
|
||||
decimal fee = _includeFeesAndSlippage ? _orderFeeEur : 0m;
|
||||
|
||||
decimal? barrier = null;
|
||||
if (_simulateKnockOutDerivatives)
|
||||
{
|
||||
decimal bufferFraction = _knockOutBufferPercent / 100m;
|
||||
barrier = setup.Direction == SignalDirection.Buy
|
||||
? setup.InvalidationPrice * (1m - bufferFraction)
|
||||
: setup.InvalidationPrice * (1m + bufferFraction);
|
||||
}
|
||||
|
||||
decimal tp1 = setup.ExitPlan.TakeProfitStages.Count > 0
|
||||
? setup.ExitPlan.TakeProfitStages[0].TargetPrice
|
||||
: (setup.Direction == SignalDirection.Buy ? executedPrice + unitRisk : executedPrice - unitRisk);
|
||||
|
||||
decimal tp2 = setup.ExitPlan.TakeProfitStages.Count > 1
|
||||
? setup.ExitPlan.TakeProfitStages[1].TargetPrice
|
||||
: (setup.Direction == SignalDirection.Buy ? executedPrice + (2.0m * unitRisk) : executedPrice - (2.0m * unitRisk));
|
||||
|
||||
var pos = new VirtualPosition
|
||||
{
|
||||
Isin = setup.Isin,
|
||||
Symbol = setup.Symbol,
|
||||
Direction = setup.Direction,
|
||||
EntryTimeUtc = candle.Timestamp,
|
||||
RawEntryPrice = setup.EntryPrice,
|
||||
ExecutedEntryPrice = executedPrice,
|
||||
TotalQuantity = quantity,
|
||||
RemainingQuantity = quantity,
|
||||
InitialStopLoss = setup.InvalidationPrice,
|
||||
CurrentStopLoss = setup.InvalidationPrice,
|
||||
TakeProfit1 = tp1,
|
||||
TakeProfit2 = tp2,
|
||||
IsKnockOut = _simulateKnockOutDerivatives,
|
||||
Barrier = barrier,
|
||||
Leverage = _targetLeverage,
|
||||
TotalFees = fee,
|
||||
MaxPriceSeen = candle.High,
|
||||
MinPriceSeen = candle.Low,
|
||||
ExitPlan = setup.ExitPlan,
|
||||
EntryAtr = setup.CurrentAtr
|
||||
};
|
||||
|
||||
_openPositions.Add(pos);
|
||||
}
|
||||
|
||||
public void UpdateActivePositions(CandleDto candle)
|
||||
{
|
||||
for (int i = _openPositions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var pos = _openPositions[i];
|
||||
pos.MaxPriceSeen = Math.Max(pos.MaxPriceSeen, candle.High);
|
||||
pos.MinPriceSeen = Math.Min(pos.MinPriceSeen, candle.Low);
|
||||
|
||||
// 1. Knock-Out Barrier Check
|
||||
if (pos.IsKnockOut && pos.Barrier.HasValue)
|
||||
{
|
||||
bool isKnockedOut = pos.Direction == SignalDirection.Buy
|
||||
? candle.Low <= pos.Barrier.Value
|
||||
: candle.High >= pos.Barrier.Value;
|
||||
|
||||
if (isKnockedOut)
|
||||
{
|
||||
ClosePosition(pos, candle.Timestamp, pos.Barrier.Value, "KnockedOut", totalLoss: true);
|
||||
_openPositions.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stop-Loss Check
|
||||
bool isStopped = pos.Direction == SignalDirection.Buy
|
||||
? candle.Low <= pos.CurrentStopLoss
|
||||
: candle.High >= pos.CurrentStopLoss;
|
||||
|
||||
if (isStopped)
|
||||
{
|
||||
decimal exitPrice = pos.CurrentStopLoss;
|
||||
string reason = pos.Tp1Hit ? "BreakEven" : "StopLoss";
|
||||
ClosePosition(pos, candle.Timestamp, exitPrice, reason);
|
||||
_openPositions.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Take-Profit 1 (Partial scale-out & Move Stop-Loss to Break-Even)
|
||||
bool isTp1 = pos.Direction == SignalDirection.Buy
|
||||
? candle.High >= pos.TakeProfit1
|
||||
: candle.Low <= pos.TakeProfit1;
|
||||
|
||||
if (isTp1 && !pos.Tp1Hit)
|
||||
{
|
||||
decimal partialQty = Math.Round(pos.TotalQuantity * 0.5m, 2);
|
||||
if (partialQty > 0 && partialQty < pos.RemainingQuantity)
|
||||
{
|
||||
decimal exitPrice = pos.TakeProfit1;
|
||||
decimal partialPnl = pos.Direction == SignalDirection.Buy
|
||||
? (exitPrice - pos.ExecutedEntryPrice) * partialQty
|
||||
: (pos.ExecutedEntryPrice - exitPrice) * partialQty;
|
||||
|
||||
pos.RealizedPnlEur += partialPnl;
|
||||
pos.RemainingQuantity -= partialQty;
|
||||
pos.Tp1Hit = true;
|
||||
pos.CurrentStopLoss = pos.ExecutedEntryPrice; // Move to Break-Even!
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Take-Profit 2 (Exit remaining position)
|
||||
bool isTp2 = pos.Direction == SignalDirection.Buy
|
||||
? candle.High >= pos.TakeProfit2
|
||||
: candle.Low <= pos.TakeProfit2;
|
||||
|
||||
if (isTp2)
|
||||
{
|
||||
ClosePosition(pos, candle.Timestamp, pos.TakeProfit2, "TP2_Hit");
|
||||
_openPositions.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Trailing Stop Update if configured. An AtrMultiplier rule is honored exactly as the strategy
|
||||
// specified it (distance = rule.Multiplier * ATR-at-entry) instead of being silently overridden by
|
||||
// a flat percent. SuperTrendLine/SwingPoints rules would need that live indicator recomputed on
|
||||
// every backtest bar, which this broker has no inputs for, so those fall back to a configurable
|
||||
// flat percent (SimulationSettingKeys.DefaultTrailingStopPercent) - an explicit, documented
|
||||
// approximation, not the previous behavior of quietly applying an unrelated hardcoded 3% to every
|
||||
// rule type regardless of what it actually specified.
|
||||
var trailingRule = pos.ExitPlan?.TrailingStopRule;
|
||||
if (pos.Tp1Hit && trailingRule != null)
|
||||
{
|
||||
decimal trailDistance = trailingRule.Type == TrailingStopType.AtrMultiplier && pos.EntryAtr > 0
|
||||
? trailingRule.Multiplier * pos.EntryAtr
|
||||
: candle.Close * (_defaultTrailingStopPercent / 100m);
|
||||
|
||||
if (pos.Direction == SignalDirection.Buy)
|
||||
{
|
||||
decimal newTrail = candle.Close - trailDistance;
|
||||
if (newTrail > pos.CurrentStopLoss) pos.CurrentStopLoss = Math.Round(newTrail, 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
decimal newTrail = candle.Close + trailDistance;
|
||||
if (newTrail < pos.CurrentStopLoss) pos.CurrentStopLoss = Math.Round(newTrail, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record Equity Point
|
||||
RecordEquity(candle.Timestamp);
|
||||
}
|
||||
|
||||
public void CloseRemainingPositions(CandleDto finalCandle)
|
||||
{
|
||||
foreach (var pos in _openPositions)
|
||||
{
|
||||
ClosePosition(pos, finalCandle.Timestamp, finalCandle.Close, "TimeExpired");
|
||||
}
|
||||
_openPositions.Clear();
|
||||
RecordEquity(finalCandle.Timestamp);
|
||||
}
|
||||
|
||||
private void ClosePosition(VirtualPosition pos, DateTime exitTime, decimal rawExitPrice, string exitReason, bool totalLoss = false)
|
||||
{
|
||||
decimal slippage = _includeFeesAndSlippage ? rawExitPrice * _slippagePercent : 0m;
|
||||
decimal exitPrice = pos.Direction == SignalDirection.Buy
|
||||
? rawExitPrice - slippage
|
||||
: rawExitPrice + slippage;
|
||||
|
||||
decimal exitFee = _includeFeesAndSlippage ? _orderFeeEur : 0m;
|
||||
pos.TotalFees += exitFee;
|
||||
|
||||
decimal finalTradePnl;
|
||||
if (totalLoss)
|
||||
{
|
||||
// Complete loss of capital allocated
|
||||
finalTradePnl = -((pos.ExecutedEntryPrice * pos.TotalQuantity) + pos.TotalFees);
|
||||
}
|
||||
else
|
||||
{
|
||||
decimal remainingPnl = pos.Direction == SignalDirection.Buy
|
||||
? (exitPrice - pos.ExecutedEntryPrice) * pos.RemainingQuantity
|
||||
: (pos.ExecutedEntryPrice - exitPrice) * pos.RemainingQuantity;
|
||||
|
||||
finalTradePnl = pos.RealizedPnlEur + remainingPnl - pos.TotalFees;
|
||||
}
|
||||
|
||||
_currentCapital += finalTradePnl;
|
||||
if (_currentCapital > _peakCapital) _peakCapital = _currentCapital;
|
||||
|
||||
decimal investedCapital = pos.ExecutedEntryPrice * pos.TotalQuantity;
|
||||
decimal returnPercent = investedCapital > 0 ? (finalTradePnl / investedCapital) * 100m : 0m;
|
||||
decimal unitRisk = Math.Abs(pos.ExecutedEntryPrice - pos.InitialStopLoss);
|
||||
decimal rMultiple = unitRisk > 0 ? finalTradePnl / (unitRisk * pos.TotalQuantity) : 0m;
|
||||
|
||||
// MAE & MFE
|
||||
decimal mae = pos.Direction == SignalDirection.Buy
|
||||
? ((pos.ExecutedEntryPrice - pos.MinPriceSeen) / pos.ExecutedEntryPrice) * 100m
|
||||
: ((pos.MaxPriceSeen - pos.ExecutedEntryPrice) / pos.ExecutedEntryPrice) * 100m;
|
||||
|
||||
decimal mfe = pos.Direction == SignalDirection.Buy
|
||||
? ((pos.MaxPriceSeen - pos.ExecutedEntryPrice) / pos.ExecutedEntryPrice) * 100m
|
||||
: ((pos.ExecutedEntryPrice - pos.MinPriceSeen) / pos.ExecutedEntryPrice) * 100m;
|
||||
|
||||
_closedTrades.Add(new BacktestTradeDto(
|
||||
TradeId: pos.PositionId,
|
||||
EntryTimeUtc: pos.EntryTimeUtc,
|
||||
ExitTimeUtc: exitTime,
|
||||
Direction: pos.Direction,
|
||||
EntryPrice: pos.ExecutedEntryPrice,
|
||||
ExitPrice: exitPrice,
|
||||
Quantity: pos.TotalQuantity,
|
||||
InitialStopLoss: pos.InitialStopLoss,
|
||||
RealizedPnlEur: Math.Round(finalTradePnl, 2),
|
||||
ReturnPercent: Math.Round(returnPercent, 2),
|
||||
RMultiple: Math.Round(rMultiple, 2),
|
||||
ExitReason: exitReason,
|
||||
MaxAdverseExcursionPercent: Math.Round(Math.Max(0m, mae), 2),
|
||||
MaxFavorableExcursionPercent: Math.Round(Math.Max(0m, mfe), 2)
|
||||
));
|
||||
}
|
||||
|
||||
private void RecordEquity(DateTime timestamp)
|
||||
{
|
||||
decimal drawdownPercent = _peakCapital > 0 ? ((_peakCapital - _currentCapital) / _peakCapital) * 100m : 0m;
|
||||
_equityCurve.Add(new EquityPointDto(
|
||||
TimestampUtc: timestamp,
|
||||
PortfolioValue: Math.Round(_currentCapital, 2),
|
||||
DrawdownPercent: Math.Round(Math.Max(0m, drawdownPercent), 2)
|
||||
));
|
||||
}
|
||||
|
||||
public BacktestReportDto BuildReport(BacktestRequestDto req, Guid runId)
|
||||
{
|
||||
int totalTrades = _closedTrades.Count;
|
||||
int winningTrades = _closedTrades.Count(t => t.RealizedPnlEur > 0);
|
||||
int losingTrades = _closedTrades.Count(t => t.RealizedPnlEur <= 0);
|
||||
|
||||
decimal winRate = totalTrades > 0 ? ((decimal)winningTrades / totalTrades) * 100m : 0m;
|
||||
decimal grossProfits = _closedTrades.Where(t => t.RealizedPnlEur > 0).Sum(t => t.RealizedPnlEur);
|
||||
decimal grossLosses = Math.Abs(_closedTrades.Where(t => t.RealizedPnlEur < 0).Sum(t => t.RealizedPnlEur));
|
||||
decimal profitFactor = grossLosses > 0 ? Math.Round(grossProfits / grossLosses, 4) : (grossProfits > 0 ? 99.0m : 1.0m);
|
||||
|
||||
decimal maxDrawdown = _equityCurve.Count > 0 ? _equityCurve.Max(p => p.DrawdownPercent) : 0m;
|
||||
decimal totalReturn = _startingCapital > 0 ? ((_currentCapital - _startingCapital) / _startingCapital) * 100m : 0m;
|
||||
|
||||
decimal avgWin = winningTrades > 0 ? grossProfits / winningTrades : 0m;
|
||||
decimal avgLoss = losingTrades > 0 ? grossLosses / losingTrades : 0m;
|
||||
decimal expectancy = totalTrades > 0 ? ((winRate / 100m) * avgWin) - ((1.0m - (winRate / 100m)) * avgLoss) : 0m;
|
||||
|
||||
// Sharpe Ratio
|
||||
decimal sharpeRatio = 0m;
|
||||
if (_closedTrades.Count > 1)
|
||||
{
|
||||
var returns = _closedTrades.Select(t => (double)t.ReturnPercent).ToList();
|
||||
double avg = returns.Average();
|
||||
double sumOfSquares = returns.Sum(d => Math.Pow(d - avg, 2));
|
||||
double stdDev = Math.Sqrt(sumOfSquares / (returns.Count - 1));
|
||||
if (stdDev > 0)
|
||||
{
|
||||
sharpeRatio = Math.Round((decimal)(avg / stdDev) * (decimal)Math.Sqrt(252), 4);
|
||||
}
|
||||
}
|
||||
|
||||
decimal avgR = totalTrades > 0 ? _closedTrades.Average(t => t.RMultiple) : 0m;
|
||||
TimeSpan avgDuration = totalTrades > 0
|
||||
? TimeSpan.FromSeconds(_closedTrades.Average(t => (t.ExitTimeUtc - t.EntryTimeUtc).TotalSeconds))
|
||||
: TimeSpan.Zero;
|
||||
|
||||
return new BacktestReportDto(
|
||||
RunId: runId,
|
||||
Isin: req.Isin,
|
||||
Symbol: req.Symbol,
|
||||
StrategyKey: req.StrategyKey,
|
||||
Timeframe: req.Timeframe,
|
||||
StartDateUtc: req.StartDateUtc,
|
||||
EndDateUtc: req.EndDateUtc,
|
||||
TotalTrades: totalTrades,
|
||||
WinningTrades: winningTrades,
|
||||
LosingTrades: losingTrades,
|
||||
WinRatePercent: Math.Round(winRate, 2),
|
||||
ProfitFactor: profitFactor,
|
||||
MaxDrawdownPercent: Math.Round(maxDrawdown, 2),
|
||||
TotalReturnPercent: Math.Round(totalReturn, 2),
|
||||
ExpectancyEur: Math.Round(expectancy, 2),
|
||||
SharpeRatio: sharpeRatio,
|
||||
AverageRiskRewardRatio: Math.Round(avgR, 2),
|
||||
AverageHoldingDuration: avgDuration,
|
||||
Trades: _closedTrades,
|
||||
EquityCurve: _equityCurve
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user