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