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!; /// ATR at entry, used to honor an AtrMultiplier trailing-stop rule honestly (see VirtualBacktestBroker.UpdateActivePositions). 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 _openPositions = new(); private readonly List _closedTrades = new(); private readonly List _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 ); } }