feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine

This commit is contained in:
2026-08-24 21:36:20 +02:00
parent f43ce2b7e9
commit a4959658a2
22 changed files with 2600 additions and 0 deletions
@@ -0,0 +1,63 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using FinlyticCore.Dtos.Simulation;
namespace FinlyticSimulation.Database.Entities;
[Table("simulation_runs")]
public class SimulationRunEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(20)]
public string Isin { get; set; } = string.Empty;
[MaxLength(30)]
public string Symbol { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string StrategyKey { get; set; } = string.Empty;
[Required]
[MaxLength(10)]
public string Timeframe { get; set; } = "15m";
public DateTime StartDateUtc { get; set; }
public DateTime EndDateUtc { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal StartingCapital { get; set; }
public int TotalTrades { get; set; }
public int WinningTrades { get; set; }
public int LosingTrades { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal WinRatePercent { get; set; }
[Column(TypeName = "decimal(8,4)")]
public decimal ProfitFactor { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal MaxDrawdownPercent { get; set; }
[Column(TypeName = "decimal(8,2)")]
public decimal TotalReturnPercent { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal ExpectancyEur { get; set; }
[Column(TypeName = "decimal(8,4)")]
public decimal SharpeRatio { get; set; }
public BacktestReportDto ReportJson { get; set; } = null!;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,44 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticSimulation.Database.Entities;
[Table("simulation_strategy_matrix")]
public class SimulationStrategyMatrixEntity
{
[Required]
[MaxLength(20)]
public string Isin { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string StrategyKey { get; set; } = string.Empty;
[Required]
[MaxLength(10)]
public string Timeframe { get; set; } = "15m";
public int SampleTradesCount { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal WinRatePercent { get; set; }
[Column(TypeName = "decimal(8,4)")]
public decimal ProfitFactor { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal MaxDrawdownPercent { get; set; }
[Column(TypeName = "decimal(5,2)")]
public decimal ReliabilityScore { get; set; } // 0 - 100
public bool IsApproved { get; set; } = true;
[MaxLength(30)]
public string RecommendedAction { get; set; } = "NEUTRAL"; // "BOOST_SCORE", "NEUTRAL", "VETO_DISABLE"
public Guid? LastBacktestRunId { get; set; }
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticSimulation.Database.Entities;
/// <summary>
/// A saved, named-by-(Isin, StrategyKey) set of tunable indicator parameter overrides (see
/// <c>TechnicalContext.ParameterOverrides</c>), so a parameter set found useful via repeated backtest
/// experimentation can be reused without retyping it every time. Purely a backtesting-side convenience - never
/// read by live scanning (<c>FinlyticTechnicals.Services.TechnicalScoringEngine</c> never queries this table).
/// </summary>
[Table("simulation_strategy_parameters")]
public class SimulationStrategyParameterEntity
{
[Required]
[MaxLength(20)]
public string Isin { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string StrategyKey { get; set; } = string.Empty;
/// <summary>Keyed by <c>"{StrategyKey}.{ParameterName}"</c>, matching <c>TechnicalContext.ParameterOverrides</c> 1:1.</summary>
public Dictionary<string, decimal> Parameters { get; set; } = new();
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using FinlyticCore.Database;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Entities.Settings;
using FinlyticSimulation.Database.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace FinlyticSimulation.Database;
public class SimulationDbContext : DbContext, ISettingsDbContext
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public SimulationDbContext(DbContextOptions<SimulationDbContext> options) : base(options)
{
}
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
public DbSet<SimulationRunEntity> SimulationRuns => Set<SimulationRunEntity>();
public DbSet<SimulationStrategyMatrixEntity> StrategyMatrix => Set<SimulationStrategyMatrixEntity>();
public DbSet<SimulationStrategyParameterEntity> StrategyParameters => Set<SimulationStrategyParameterEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 1. Settings Table
modelBuilder.Entity<SettingEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique();
});
// 2. Report JSONB Converter
var reportConverter = new ValueConverter<BacktestReportDto, string>(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<BacktestReportDto>(v, JsonOptions) ?? new BacktestReportDto(
Guid.Empty, "", "", "", "", DateTime.UtcNow, DateTime.UtcNow, 0, 0, 0, 0m, 0m, 0m, 0m, 0m, 0m, 0m, TimeSpan.Zero, new List<BacktestTradeDto>(), new List<EquityPointDto>())
);
// 3. Simulation Runs Table
modelBuilder.Entity<SimulationRunEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.Isin, e.StrategyKey, e.Timeframe });
entity.HasIndex(e => e.CreatedAtUtc);
entity.Property(e => e.ReportJson)
.HasColumnType("jsonb")
.HasConversion(reportConverter);
});
// 4. Strategy Matrix Table
modelBuilder.Entity<SimulationStrategyMatrixEntity>(entity =>
{
entity.HasKey(e => new { e.Isin, e.StrategyKey, e.Timeframe });
entity.HasIndex(e => new { e.Isin, e.IsApproved });
entity.HasIndex(e => e.ReliabilityScore);
});
// 5. Saved Strategy Parameter Profiles Table
var parametersConverter = new ValueConverter<Dictionary<string, decimal>, string>(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<Dictionary<string, decimal>>(v, JsonOptions) ?? new Dictionary<string, decimal>()
);
modelBuilder.Entity<SimulationStrategyParameterEntity>(entity =>
{
entity.HasKey(e => new { e.Isin, e.StrategyKey });
entity.Property(e => e.Parameters)
.HasColumnType("jsonb")
.HasConversion(parametersConverter);
});
}
}
public class SimulationDbContextFactory : IDesignTimeDbContextFactory<SimulationDbContext>
{
public SimulationDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<SimulationDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_simulation;Username=postgres;Password=postgres");
return new SimulationDbContext(optionsBuilder.Options);
}
}
+23
View File
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FinlyticSimulation/FinlyticSimulation.csproj", "FinlyticSimulation/"]
COPY ["FinlyticTechnicals/FinlyticTechnicals.csproj", "FinlyticTechnicals/"]
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
RUN dotnet restore "FinlyticSimulation/FinlyticSimulation.csproj"
COPY . .
WORKDIR "/src/FinlyticSimulation"
RUN dotnet build "FinlyticSimulation.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "FinlyticSimulation.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FinlyticSimulation.dll"]
@@ -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
);
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
<ProjectReference Include="..\FinlyticTechnicals\FinlyticTechnicals.csproj">
<ExcludeAssets>contentFiles</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>
@@ -0,0 +1,191 @@
// <auto-generated />
using System;
using FinlyticSimulation.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticSimulation.Migrations
{
[DbContext(typeof(SimulationDbContext))]
[Migration("20260819191418_InitialSimulationMigration")]
partial class InitialSimulationMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationRunEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndDateUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("ExpectancyEur")
.HasColumnType("decimal(18,4)");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("LosingTrades")
.HasColumnType("integer");
b.Property<decimal>("MaxDrawdownPercent")
.HasColumnType("decimal(6,2)");
b.Property<decimal>("ProfitFactor")
.HasColumnType("decimal(8,4)");
b.Property<string>("ReportJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<decimal>("SharpeRatio")
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("StartDateUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("StartingCapital")
.HasColumnType("decimal(18,4)");
b.Property<string>("StrategyKey")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Timeframe")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<decimal>("TotalReturnPercent")
.HasColumnType("decimal(8,2)");
b.Property<int>("TotalTrades")
.HasColumnType("integer");
b.Property<decimal>("WinRatePercent")
.HasColumnType("decimal(6,2)");
b.Property<int>("WinningTrades")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAtUtc");
b.HasIndex("Isin", "StrategyKey", "Timeframe");
b.ToTable("simulation_runs");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyMatrixEntity", b =>
{
b.Property<string>("Isin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StrategyKey")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Timeframe")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("IsApproved")
.HasColumnType("boolean");
b.Property<Guid?>("LastBacktestRunId")
.HasColumnType("uuid");
b.Property<decimal>("MaxDrawdownPercent")
.HasColumnType("decimal(6,2)");
b.Property<decimal>("ProfitFactor")
.HasColumnType("decimal(8,4)");
b.Property<string>("RecommendedAction")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal>("ReliabilityScore")
.HasColumnType("decimal(5,2)");
b.Property<int>("SampleTradesCount")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("WinRatePercent")
.HasColumnType("decimal(6,2)");
b.HasKey("Isin", "StrategyKey", "Timeframe");
b.HasIndex("ReliabilityScore");
b.HasIndex("Isin", "IsApproved");
b.ToTable("simulation_strategy_matrix");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,120 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticSimulation.Migrations
{
/// <inheritdoc />
public partial class InitialSimulationMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "simulation_runs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
StrategyKey = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Timeframe = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
StartDateUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndDateUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
StartingCapital = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
TotalTrades = table.Column<int>(type: "integer", nullable: false),
WinningTrades = table.Column<int>(type: "integer", nullable: false),
LosingTrades = table.Column<int>(type: "integer", nullable: false),
WinRatePercent = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
ProfitFactor = table.Column<decimal>(type: "numeric(8,4)", nullable: false),
MaxDrawdownPercent = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
TotalReturnPercent = table.Column<decimal>(type: "numeric(8,2)", nullable: false),
ExpectancyEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
SharpeRatio = table.Column<decimal>(type: "numeric(8,4)", nullable: false),
ReportJson = table.Column<string>(type: "jsonb", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_simulation_runs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "simulation_strategy_matrix",
columns: table => new
{
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StrategyKey = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Timeframe = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
SampleTradesCount = table.Column<int>(type: "integer", nullable: false),
WinRatePercent = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
ProfitFactor = table.Column<decimal>(type: "numeric(8,4)", nullable: false),
MaxDrawdownPercent = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
ReliabilityScore = table.Column<decimal>(type: "numeric(5,2)", nullable: false),
IsApproved = table.Column<bool>(type: "boolean", nullable: false),
RecommendedAction = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
LastBacktestRunId = table.Column<Guid>(type: "uuid", nullable: true),
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_simulation_strategy_matrix", x => new { x.Isin, x.StrategyKey, x.Timeframe });
});
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_simulation_runs_CreatedAtUtc",
table: "simulation_runs",
column: "CreatedAtUtc");
migrationBuilder.CreateIndex(
name: "IX_simulation_runs_Isin_StrategyKey_Timeframe",
table: "simulation_runs",
columns: new[] { "Isin", "StrategyKey", "Timeframe" });
migrationBuilder.CreateIndex(
name: "IX_simulation_strategy_matrix_Isin_IsApproved",
table: "simulation_strategy_matrix",
columns: new[] { "Isin", "IsApproved" });
migrationBuilder.CreateIndex(
name: "IX_simulation_strategy_matrix_ReliabilityScore",
table: "simulation_strategy_matrix",
column: "ReliabilityScore");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DynamicSettings");
migrationBuilder.DropTable(
name: "simulation_runs");
migrationBuilder.DropTable(
name: "simulation_strategy_matrix");
}
}
}
@@ -0,0 +1,213 @@
// <auto-generated />
using System;
using FinlyticSimulation.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticSimulation.Migrations
{
[DbContext(typeof(SimulationDbContext))]
[Migration("20260822095033_AddStrategyParameterProfiles")]
partial class AddStrategyParameterProfiles
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationRunEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndDateUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("ExpectancyEur")
.HasColumnType("decimal(18,4)");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("LosingTrades")
.HasColumnType("integer");
b.Property<decimal>("MaxDrawdownPercent")
.HasColumnType("decimal(6,2)");
b.Property<decimal>("ProfitFactor")
.HasColumnType("decimal(8,4)");
b.Property<string>("ReportJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<decimal>("SharpeRatio")
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("StartDateUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("StartingCapital")
.HasColumnType("decimal(18,4)");
b.Property<string>("StrategyKey")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Timeframe")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<decimal>("TotalReturnPercent")
.HasColumnType("decimal(8,2)");
b.Property<int>("TotalTrades")
.HasColumnType("integer");
b.Property<decimal>("WinRatePercent")
.HasColumnType("decimal(6,2)");
b.Property<int>("WinningTrades")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAtUtc");
b.HasIndex("Isin", "StrategyKey", "Timeframe");
b.ToTable("simulation_runs");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyMatrixEntity", b =>
{
b.Property<string>("Isin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StrategyKey")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Timeframe")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("IsApproved")
.HasColumnType("boolean");
b.Property<Guid?>("LastBacktestRunId")
.HasColumnType("uuid");
b.Property<decimal>("MaxDrawdownPercent")
.HasColumnType("decimal(6,2)");
b.Property<decimal>("ProfitFactor")
.HasColumnType("decimal(8,4)");
b.Property<string>("RecommendedAction")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal>("ReliabilityScore")
.HasColumnType("decimal(5,2)");
b.Property<int>("SampleTradesCount")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("WinRatePercent")
.HasColumnType("decimal(6,2)");
b.HasKey("Isin", "StrategyKey", "Timeframe");
b.HasIndex("ReliabilityScore");
b.HasIndex("Isin", "IsApproved");
b.ToTable("simulation_strategy_matrix");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyParameterEntity", b =>
{
b.Property<string>("Isin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StrategyKey")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Parameters")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Isin", "StrategyKey");
b.ToTable("simulation_strategy_parameters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticSimulation.Migrations
{
/// <inheritdoc />
public partial class AddStrategyParameterProfiles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "simulation_strategy_parameters",
columns: table => new
{
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StrategyKey = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Parameters = table.Column<string>(type: "jsonb", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_simulation_strategy_parameters", x => new { x.Isin, x.StrategyKey });
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "simulation_strategy_parameters");
}
}
}
@@ -0,0 +1,210 @@
// <auto-generated />
using System;
using FinlyticSimulation.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticSimulation.Migrations
{
[DbContext(typeof(SimulationDbContext))]
partial class SimulationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationRunEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndDateUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("ExpectancyEur")
.HasColumnType("decimal(18,4)");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("LosingTrades")
.HasColumnType("integer");
b.Property<decimal>("MaxDrawdownPercent")
.HasColumnType("decimal(6,2)");
b.Property<decimal>("ProfitFactor")
.HasColumnType("decimal(8,4)");
b.Property<string>("ReportJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<decimal>("SharpeRatio")
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("StartDateUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("StartingCapital")
.HasColumnType("decimal(18,4)");
b.Property<string>("StrategyKey")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Timeframe")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<decimal>("TotalReturnPercent")
.HasColumnType("decimal(8,2)");
b.Property<int>("TotalTrades")
.HasColumnType("integer");
b.Property<decimal>("WinRatePercent")
.HasColumnType("decimal(6,2)");
b.Property<int>("WinningTrades")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAtUtc");
b.HasIndex("Isin", "StrategyKey", "Timeframe");
b.ToTable("simulation_runs");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyMatrixEntity", b =>
{
b.Property<string>("Isin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StrategyKey")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Timeframe")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("IsApproved")
.HasColumnType("boolean");
b.Property<Guid?>("LastBacktestRunId")
.HasColumnType("uuid");
b.Property<decimal>("MaxDrawdownPercent")
.HasColumnType("decimal(6,2)");
b.Property<decimal>("ProfitFactor")
.HasColumnType("decimal(8,4)");
b.Property<string>("RecommendedAction")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal>("ReliabilityScore")
.HasColumnType("decimal(5,2)");
b.Property<int>("SampleTradesCount")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("WinRatePercent")
.HasColumnType("decimal(6,2)");
b.HasKey("Isin", "StrategyKey", "Timeframe");
b.HasIndex("ReliabilityScore");
b.HasIndex("Isin", "IsApproved");
b.ToTable("simulation_strategy_matrix");
});
modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyParameterEntity", b =>
{
b.Property<string>("Isin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StrategyKey")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Parameters")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Isin", "StrategyKey");
b.ToTable("simulation_strategy_parameters");
});
#pragma warning restore 612, 618
}
}
}
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Net.Http;
using FinlyticCore.Database;
using FinlyticCore.Services;
using FinlyticCore.Services.Yahoo;
using FinlyticSimulation.Database;
using FinlyticSimulation.Services;
using FinlyticSimulation.Services.Mqtt;
using FinlyticSimulation.Util;
using FinlyticTechnicals.Patterns;
using FinlyticTechnicals.Patterns.Candlesticks;
using FinlyticTechnicals.Patterns.ChartPatterns;
using FinlyticTechnicals.Patterns.SmartMoney;
using FinlyticTechnicals.Services;
using FinlyticTechnicals.Strategies;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
// 1. Register DbContext & Settings
builder.Services.AddDbContext<SimulationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<SimulationDbContext>());
// 2. Register Core Services & Logger
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
// 3. Register HTTP & Yahoo Scraper
builder.Services.AddHttpClient<IYahooMarketDataScraper, YahooMarketDataScraper>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
UseCookies = true,
CookieContainer = new System.Net.CookieContainer()
});
builder.Services.AddSingleton<YahooFinanceClient>();
builder.Services.AddTransient<IYahooMarketDataScraper, YahooMarketDataScraper>();
// 4. Register Pattern Detectors (100% Code-Reuse from FTA)
builder.Services.AddSingleton<IPatternDetector, HammerShootingStarDetector>();
builder.Services.AddSingleton<IPatternDetector, EngulfingPatternDetector>();
builder.Services.AddSingleton<IPatternDetector, MorningEveningStarDetector>();
builder.Services.AddSingleton<IPatternDetector, DojiPatternDetector>();
builder.Services.AddSingleton<IPatternDetector, DoubleTopBottomDetector>();
builder.Services.AddSingleton<IPatternDetector, HeadAndShouldersDetector>();
builder.Services.AddSingleton<IPatternDetector, TrianglePatternDetector>();
builder.Services.AddSingleton<IPatternDetector, FairValueGapDetector>();
builder.Services.AddSingleton<IPatternDetector, LiquiditySweepDetector>();
builder.Services.AddSingleton<IPatternDetector, ChochBosDetector>();
builder.Services.AddSingleton<IPatternDetector, OrderBlockDetector>();
// 5. Register Technical Strategies (100% Code-Reuse from FTA)
builder.Services.AddSingleton<ITechnicalStrategy, TrendPullbackFvgStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, VolatilitySqueezeStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, SmcLiquiditySweepStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, MeanReversionStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, SuperTrendMultiTfStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, MacdCrossoverStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, MovingAverageCrossoverStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, RsiReversalStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, DonchianBreakoutStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, VwapBounceStrategy>();
// 6. Register Quant Simulation Engine
builder.Services.AddSingleton<IQuantSimulationEngine, QuantSimulationEngine>();
// 7. Register MQTT Client & RPC Bridge
builder.Services.AddSingleton<SimulationMqttClient>();
builder.Services.AddSingleton<ISimulationRpcClient>(sp => sp.GetRequiredService<SimulationMqttClient>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<SimulationMqttClient>());
// 8. Register Scheduled Reliability Matrix Recompute
builder.Services.AddHostedService<ReliabilityMatrixRecomputeBackgroundService>();
var host = builder.Build();
// Run startup database migrations
using (var scope = host.Services.CreateScope())
{
try
{
var context = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
await context.MigrateWithBootstrapAsync(connStr);
Console.WriteLine("Database migrations successfully executed for FinlyticSimulation.");
}
catch (Exception ex)
{
Console.WriteLine($"Migration notice on startup: {ex.Message}");
}
}
await host.RunAsync();
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Simulation;
namespace FinlyticSimulation.Services;
public interface IQuantSimulationEngine
{
Task<BacktestReportDto> RunBacktestAsync(BacktestRequestDto request, CancellationToken cancellationToken = default);
Task<StrategyAssetReliabilityDto?> GetStrategyReliabilityAsync(string isin, string strategyKey, string timeframe = "15m", CancellationToken cancellationToken = default);
Task<List<StrategyAssetReliabilityDto>> GetMatrixForAssetAsync(string isin, CancellationToken cancellationToken = default);
/// <summary>Lightweight, paginated history of past backtest runs for an ISIN (see <see cref="BacktestHistoryEntryDto"/>).</summary>
Task<List<BacktestHistoryEntryDto>> GetBacktestHistoryAsync(GetBacktestHistoryRequest request, CancellationToken cancellationToken = default);
/// <summary>Full, already-persisted report for one past run, or <see langword="null"/> if the RunId doesn't exist.</summary>
Task<BacktestReportDto?> GetBacktestRunDetailAsync(Guid runId, CancellationToken cancellationToken = default);
/// <summary>Saved parameter profile for one (Isin, StrategyKey) pair, or <see langword="null"/> if none was ever saved.</summary>
Task<StrategyParameterProfileDto?> GetStrategyParametersAsync(string isin, string strategyKey, CancellationToken cancellationToken = default);
/// <summary>Upserts a saved parameter profile for one (Isin, StrategyKey) pair.</summary>
Task<StrategyParameterProfileDto> SaveStrategyParametersAsync(string isin, string strategyKey, Dictionary<string, decimal> parameters, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace FinlyticSimulation.Services.Mqtt;
public interface ISimulationRpcClient
{
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
string channel,
TRequest requestData,
TimeSpan? timeout = null)
where TResponse : class
where TRequest : class;
Task PublishAsync<T>(string topic, T data, bool retain = false);
}
@@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticSimulation.Database;
using FinlyticSimulation.Database.Entities;
using FinlyticSimulation.Engine;
using FinlyticSimulation.Services.Mqtt;
using FinlyticSimulation.Settings;
using FinlyticTechnicals.Patterns;
using FinlyticTechnicals.Services;
using FinlyticTechnicals.Strategies;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticSimulation.Services;
public record SimGetCandlesRequest(string Isin, string Timeframe);
public class QuantSimulationEngine : IQuantSimulationEngine
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IEnumerable<ITechnicalStrategy> _strategies;
private readonly IEnumerable<IPatternDetector> _patternDetectors;
private readonly IYahooMarketDataScraper _yahooScraper;
private readonly ISimulationRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<QuantSimulationEngine> _logger;
public QuantSimulationEngine(
IServiceScopeFactory scopeFactory,
IEnumerable<ITechnicalStrategy> strategies,
IEnumerable<IPatternDetector> patternDetectors,
IYahooMarketDataScraper yahooScraper,
ISimulationRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<QuantSimulationEngine> logger)
{
_scopeFactory = scopeFactory;
_strategies = strategies;
_patternDetectors = patternDetectors;
_yahooScraper = yahooScraper;
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
public async Task<BacktestReportDto> RunBacktestAsync(BacktestRequestDto request, CancellationToken cancellationToken = default)
{
var cleanIsin = request.Isin.Trim().ToUpperInvariant();
var strategyKey = request.StrategyKey.Trim();
var strategy = _strategies.FirstOrDefault(s => string.Equals(s.StrategyKey, strategyKey, StringComparison.OrdinalIgnoreCase));
if (strategy == null)
{
throw new ArgumentException($"Technical Strategy '{strategyKey}' not recognized or registered.");
}
await _logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
"[SimulationEngine] Starting backtest for {Isin} ({Symbol}) using strategy {Strategy} on {Timeframe}...",
cleanIsin, request.Symbol, strategy.StrategyName, request.Timeframe);
// 1. Fetch Historical Candles (first try Yahoo, then FTA fallback)
IReadOnlyList<CandleDto>? candles = null;
try
{
string ticker = request.Symbol;
if (string.IsNullOrWhiteSpace(ticker))
{
ticker = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
candles = await _yahooScraper.FetchHistoricalCandlesAsync(ticker, range: ResolveYahooRange(request.Timeframe), interval: request.Timeframe, cancellationToken);
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SimulationSettingKeys.SimulationChannel, ex,
"[SimulationEngine] Yahoo candle fetch failed for {Isin}. Trying FTA RPC.", cleanIsin);
}
if (candles == null || candles.Count < 30)
{
candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, SimGetCandlesRequest>(
"ta_GetCandles",
new SimGetCandlesRequest(cleanIsin, request.Timeframe),
TimeSpan.FromSeconds(5)
);
}
if (candles == null || candles.Count < 30)
{
throw new InvalidOperationException($"Insufficient historical candle data found for {cleanIsin} to execute backtest.");
}
// Filter date range if specified
var filteredCandles = candles
.Where(c => c.Timestamp >= request.StartDateUtc && c.Timestamp <= request.EndDateUtc)
.OrderBy(c => c.Timestamp)
.ToList();
if (filteredCandles.Count < 30)
{
filteredCandles = candles.OrderBy(c => c.Timestamp).ToList();
}
// 2. Run Replay
var slippagePercent = await _settingsService.GetSettingAsync(SimulationSettingKeys.DefaultSlippagePercent, cancellationToken);
var orderFeeEur = await _settingsService.GetSettingAsync(SimulationSettingKeys.DefaultOrderFeeEur, cancellationToken);
var knockOutBufferPercent = await _settingsService.GetSettingAsync(SimulationSettingKeys.KnockOutBarrierBufferPercent, cancellationToken);
var defaultTrailingStopPercent = await _settingsService.GetSettingAsync(SimulationSettingKeys.DefaultTrailingStopPercent, cancellationToken);
var runner = new HistoricalReplayRunner(strategy, _patternDetectors);
var report = runner.Run(filteredCandles, request, slippagePercent, orderFeeEur, knockOutBufferPercent, defaultTrailingStopPercent);
// 3. Persist Simulation Run to DB
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var runEntity = new SimulationRunEntity
{
Id = report.RunId,
Isin = cleanIsin,
Symbol = request.Symbol,
StrategyKey = strategy.StrategyKey,
Timeframe = request.Timeframe,
StartDateUtc = report.StartDateUtc,
EndDateUtc = report.EndDateUtc,
StartingCapital = request.StartingCapital,
TotalTrades = report.TotalTrades,
WinningTrades = report.WinningTrades,
LosingTrades = report.LosingTrades,
WinRatePercent = report.WinRatePercent,
ProfitFactor = report.ProfitFactor,
MaxDrawdownPercent = report.MaxDrawdownPercent,
TotalReturnPercent = report.TotalReturnPercent,
ExpectancyEur = report.ExpectancyEur,
SharpeRatio = report.SharpeRatio,
ReportJson = report,
CreatedAtUtc = DateTime.UtcNow
};
db.SimulationRuns.Add(runEntity);
// 4. Update Strategy Reliability Matrix
decimal minTrades = await _settingsService.GetSettingAsync(SimulationSettingKeys.MinSampleTradesForApproval, cancellationToken);
decimal highPf = await _settingsService.GetSettingAsync(SimulationSettingKeys.HighProfitFactorThreshold, cancellationToken);
decimal lowPf = await _settingsService.GetSettingAsync(SimulationSettingKeys.LowProfitFactorThreshold, cancellationToken);
var verdict = ReliabilityMatrixCalculator.Calculate(report, minTrades, highPf, lowPf);
var matrixEntry = await db.StrategyMatrix.FirstOrDefaultAsync(
m => m.Isin == cleanIsin && m.StrategyKey == strategy.StrategyKey && m.Timeframe == request.Timeframe,
cancellationToken);
if (matrixEntry == null)
{
matrixEntry = new SimulationStrategyMatrixEntity
{
Isin = cleanIsin,
StrategyKey = strategy.StrategyKey,
Timeframe = request.Timeframe,
SampleTradesCount = report.TotalTrades,
WinRatePercent = report.WinRatePercent,
ProfitFactor = report.ProfitFactor,
MaxDrawdownPercent = report.MaxDrawdownPercent,
ReliabilityScore = verdict.ReliabilityScore,
IsApproved = verdict.IsApproved,
RecommendedAction = verdict.RecommendedAction,
LastBacktestRunId = report.RunId,
UpdatedAtUtc = DateTime.UtcNow
};
db.StrategyMatrix.Add(matrixEntry);
}
else
{
matrixEntry.SampleTradesCount = report.TotalTrades;
matrixEntry.WinRatePercent = report.WinRatePercent;
matrixEntry.ProfitFactor = report.ProfitFactor;
matrixEntry.MaxDrawdownPercent = report.MaxDrawdownPercent;
matrixEntry.ReliabilityScore = verdict.ReliabilityScore;
matrixEntry.IsApproved = verdict.IsApproved;
matrixEntry.RecommendedAction = verdict.RecommendedAction;
matrixEntry.LastBacktestRunId = report.RunId;
matrixEntry.UpdatedAtUtc = DateTime.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
"[SimulationEngine] Backtest finished for {Isin} ({Strategy}): Trades={Trades}, WR={WR:F1}%, PF={PF:F2}, Action={Action}",
cleanIsin, strategy.StrategyKey, report.TotalTrades, report.WinRatePercent, report.ProfitFactor, verdict.RecommendedAction);
}
return report;
}
public async Task<StrategyAssetReliabilityDto?> GetStrategyReliabilityAsync(
string isin,
string strategyKey,
string timeframe = "15m",
CancellationToken cancellationToken = default)
{
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var entry = await db.StrategyMatrix.AsNoTracking().FirstOrDefaultAsync(
m => m.Isin == cleanIsin && m.StrategyKey == strategyKey && m.Timeframe == timeframe,
cancellationToken);
if (entry == null) return null;
return new StrategyAssetReliabilityDto(
Isin: entry.Isin,
StrategyKey: entry.StrategyKey,
ReliabilityScore: entry.ReliabilityScore,
WinRatePercent: entry.WinRatePercent,
ProfitFactor: entry.ProfitFactor,
SampleTradeCount: entry.SampleTradesCount,
IsStrategyApprovedForAsset: entry.IsApproved,
RecommendedAction: entry.RecommendedAction
);
}
public async Task<List<StrategyAssetReliabilityDto>> GetMatrixForAssetAsync(
string isin,
CancellationToken cancellationToken = default)
{
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var entries = await db.StrategyMatrix.AsNoTracking()
.Where(m => m.Isin == cleanIsin)
.OrderByDescending(m => m.ReliabilityScore)
.ToListAsync(cancellationToken);
return entries.Select(e => new StrategyAssetReliabilityDto(
Isin: e.Isin,
StrategyKey: e.StrategyKey,
ReliabilityScore: e.ReliabilityScore,
WinRatePercent: e.WinRatePercent,
ProfitFactor: e.ProfitFactor,
SampleTradeCount: e.SampleTradesCount,
IsStrategyApprovedForAsset: e.IsApproved,
RecommendedAction: e.RecommendedAction
)).ToList();
}
/// <inheritdoc />
public async Task<List<BacktestHistoryEntryDto>> GetBacktestHistoryAsync(GetBacktestHistoryRequest request, CancellationToken cancellationToken = default)
{
var cleanIsin = request.Isin.Trim().ToUpperInvariant();
int limit = Math.Clamp(request.Limit <= 0 ? 20 : request.Limit, 1, 100);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var query = db.SimulationRuns.AsNoTracking().Where(r => r.Isin == cleanIsin);
if (!string.IsNullOrWhiteSpace(request.StrategyKey))
{
query = query.Where(r => r.StrategyKey == request.StrategyKey);
}
var runs = await query
.OrderByDescending(r => r.CreatedAtUtc)
.Take(limit)
.ToListAsync(cancellationToken);
return runs.Select(r => new BacktestHistoryEntryDto(
RunId: r.Id,
Isin: r.Isin,
Symbol: r.Symbol,
StrategyKey: r.StrategyKey,
Timeframe: r.Timeframe,
StartDateUtc: r.StartDateUtc,
EndDateUtc: r.EndDateUtc,
TotalTrades: r.TotalTrades,
WinRatePercent: r.WinRatePercent,
ProfitFactor: r.ProfitFactor,
MaxDrawdownPercent: r.MaxDrawdownPercent,
TotalReturnPercent: r.TotalReturnPercent,
SharpeRatio: r.SharpeRatio,
CreatedAtUtc: r.CreatedAtUtc
)).ToList();
}
/// <inheritdoc />
public async Task<BacktestReportDto?> GetBacktestRunDetailAsync(Guid runId, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var run = await db.SimulationRuns.AsNoTracking().FirstOrDefaultAsync(r => r.Id == runId, cancellationToken);
return run?.ReportJson;
}
/// <inheritdoc />
public async Task<StrategyParameterProfileDto?> GetStrategyParametersAsync(string isin, string strategyKey, CancellationToken cancellationToken = default)
{
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var entity = await db.StrategyParameters.AsNoTracking()
.FirstOrDefaultAsync(p => p.Isin == cleanIsin && p.StrategyKey == strategyKey, cancellationToken);
if (entity == null) return null;
return new StrategyParameterProfileDto(entity.Isin, entity.StrategyKey, entity.Parameters, entity.UpdatedAtUtc);
}
/// <inheritdoc />
public async Task<StrategyParameterProfileDto> SaveStrategyParametersAsync(string isin, string strategyKey, Dictionary<string, decimal> parameters, CancellationToken cancellationToken = default)
{
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var entity = await db.StrategyParameters
.FirstOrDefaultAsync(p => p.Isin == cleanIsin && p.StrategyKey == strategyKey, cancellationToken);
var now = DateTime.UtcNow;
if (entity == null)
{
entity = new SimulationStrategyParameterEntity
{
Isin = cleanIsin,
StrategyKey = strategyKey,
Parameters = parameters,
UpdatedAtUtc = now
};
db.StrategyParameters.Add(entity);
}
else
{
entity.Parameters = parameters;
entity.UpdatedAtUtc = now;
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
"[SimulationEngine] Saved parameter profile for {Isin} ({Strategy}): {Count} override(s).",
cleanIsin, strategyKey, parameters.Count);
return new StrategyParameterProfileDto(entity.Isin, entity.StrategyKey, entity.Parameters, entity.UpdatedAtUtc);
}
/// <summary>
/// Picks the Yahoo Finance chart-API <c>range</c> query parameter to request for a given candle
/// <paramref name="timeframe"/>, honoring Yahoo's real, publicly documented per-interval history limits
/// (the same limits every Yahoo-chart-API client, e.g. Python's <c>yfinance</c>, has to respect) instead of
/// the previous hardcoded <c>"2y"</c> for every interval - which silently under-delivered for anything
/// finer than 1h (Yahoo does not retain 2 years of 5m/15m/30m bars) and needlessly under-fetched for 1d/1wk
/// (which Yahoo happily serves far beyond 2 years). This directly determines how much real history a
/// backtest on a given timeframe can actually cover.
/// </summary>
private static string ResolveYahooRange(string timeframe) => timeframe.Trim().ToLowerInvariant() switch
{
"1m" => "7d",
"5m" or "15m" or "30m" => "60d",
"1h" or "60m" => "730d",
"1wk" => "10y",
_ => "5y" // 1d and anything else Yahoo retains for many years.
};
}
@@ -0,0 +1,51 @@
using System;
using FinlyticCore.Dtos.Simulation;
namespace FinlyticSimulation.Services;
/// <summary>
/// Pure scoring function for FinlyticSimulation's backtest-reliability matrix, extracted out of
/// <c>QuantSimulationEngine.RunBacktestAsync</c> (which previously mixed candle-fetch-with-fallback, replay
/// orchestration, DB persistence, AND this scoring math into one large method with inline magic numbers). No
/// I/O, no DB access - just <see cref="BacktestReportDto"/> + threshold settings in, a verdict out, so this is
/// independently unit-testable without spinning up a DbContext or a real backtest.
/// </summary>
public static class ReliabilityMatrixCalculator
{
/// <param name="ReliabilityScore">0-100, a blend of profit factor (max 1.5x weight, capped) and win rate.</param>
/// <param name="IsApproved">
/// Whether <see cref="Scoring.ICompositeOpportunityScorer"/>-style consumers should trust this
/// strategy/asset combination. Defaults to approved when there isn't yet enough sample data to judge it
/// (Rules.md §4: "not enough data" must never read the same as "actively vetoed").
/// </param>
/// <param name="RecommendedAction">"BOOST_SCORE" / "NEUTRAL" / "VETO_DISABLE" - see <see cref="Calculate"/>.</param>
public record Result(decimal ReliabilityScore, bool IsApproved, string RecommendedAction);
/// <summary>
/// Scores a single completed backtest report against the given approval thresholds
/// (<c>SimulationSettingKeys.MinSampleTradesForApproval</c>/<c>HighProfitFactorThreshold</c>/<c>LowProfitFactorThreshold</c>).
/// </summary>
public static Result Calculate(
BacktestReportDto report,
decimal minSampleTrades,
decimal highProfitFactorThreshold,
decimal lowProfitFactorThreshold)
{
// 0..100 blend: profit factor contributes up to 75 points (capped at PF=3.0 -> 1.5 * 50), win rate
// contributes up to 50 points (100% WR * 0.5) - deliberately not a simple average, since a high win
// rate with a poor profit factor (many tiny wins, rare huge losses) should not score as "reliable".
decimal rawScore = (Math.Clamp(report.ProfitFactor / 2.0m, 0m, 1.5m) * 50m) + (report.WinRatePercent * 0.5m);
decimal reliabilityScore = Math.Clamp(Math.Round(rawScore, 2), 0m, 100m);
bool isApproved = report.ProfitFactor >= lowProfitFactorThreshold || report.TotalTrades < minSampleTrades;
string recommendedAction = "NEUTRAL";
if (report.TotalTrades >= minSampleTrades)
{
if (report.ProfitFactor >= highProfitFactorThreshold) recommendedAction = "BOOST_SCORE";
else if (report.ProfitFactor < lowProfitFactorThreshold) recommendedAction = "VETO_DISABLE";
}
return new Result(reliabilityScore, isApproved, recommendedAction);
}
}
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Services;
using FinlyticSimulation.Database;
using FinlyticSimulation.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticSimulation.Services;
/// <summary>
/// Keeps the backtest-reliability matrix (<c>SimulationStrategyMatrixEntity</c>) fresh on a schedule, instead
/// of it only ever being updated as a side effect of a human manually re-running the exact same backtest (the
/// previous behavior - there was no scheduled/background recompute job at all). Every stale (Isin, StrategyKey,
/// Timeframe) row already present in the matrix gets a fresh 2-year backtest re-run; rows are never added
/// speculatively for combinations nobody has ever backtested (Rules.md §4 - this refreshes existing data, it
/// does not invent new coverage).
/// </summary>
public class ReliabilityMatrixRecomputeBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<ReliabilityMatrixRecomputeBackgroundService> _logger;
public ReliabilityMatrixRecomputeBackgroundService(
IServiceScopeFactory scopeFactory,
ISettingsService settingsService,
IFinlyticLogger<ReliabilityMatrixRecomputeBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_settingsService = settingsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Starting reliability matrix recompute background service.");
// Initial grace delay for MQTT/DB connections to stabilize.
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var enabled = await _settingsService.GetSettingAsync(SimulationSettingKeys.EnableScheduledMatrixRecompute, stoppingToken);
if (enabled)
{
await RecomputeStaleEntriesAsync(stoppingToken);
}
else
{
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Scheduled recompute is disabled via settings. Skipping this cycle.");
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(SimulationSettingKeys.MatrixChannel, ex,
"[MatrixRecompute] Unexpected error in recompute cycle.");
}
var checkIntervalMinutes = await _settingsService.GetSettingAsync(SimulationSettingKeys.MatrixRecomputeCheckIntervalMinutes, stoppingToken);
try
{
await Task.Delay(TimeSpan.FromMinutes(Math.Max(5, checkIntervalMinutes)), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Reliability matrix recompute background service stopped.");
}
private async Task RecomputeStaleEntriesAsync(CancellationToken stoppingToken)
{
var intervalHours = await _settingsService.GetSettingAsync(SimulationSettingKeys.MatrixRecomputeIntervalHours, stoppingToken);
var staleCutoff = DateTime.UtcNow.AddHours(-Math.Max(1, intervalHours));
List<(string Isin, string StrategyKey, string Timeframe)> staleEntries;
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var rows = await db.StrategyMatrix
.AsNoTracking()
.Where(m => m.UpdatedAtUtc <= staleCutoff)
.Select(m => new { m.Isin, m.StrategyKey, m.Timeframe })
.ToListAsync(stoppingToken);
staleEntries = rows.Select(m => (m.Isin, m.StrategyKey, m.Timeframe)).ToList();
}
if (staleEntries.Count == 0)
{
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] No stale reliability matrix entries found this cycle.");
return;
}
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Refreshing {Count} stale reliability matrix entries (older than {Hours}h)...",
staleEntries.Count, intervalHours);
var now = DateTime.UtcNow;
foreach (var (isin, strategyKey, timeframe) in staleEntries)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
var request = new BacktestRequestDto(
Isin: isin,
Symbol: "", // Resolved from the ISIN by QuantSimulationEngine itself.
StrategyKey: strategyKey,
Timeframe: timeframe,
StartDateUtc: now.AddYears(-2),
EndDateUtc: now
);
await simEngine.RunBacktestAsync(request, stoppingToken);
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SimulationSettingKeys.MatrixChannel, ex,
"[MatrixRecompute] Failed to refresh matrix entry for {Isin} ({Strategy}/{Timeframe}).",
isin, strategyKey, timeframe);
}
// Gentle throttle so this doesn't hammer Yahoo/FinlyticTechnicals with back-to-back requests.
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
@@ -0,0 +1,53 @@
using FinlyticCore.Models.Settings;
namespace FinlyticSimulation.Settings;
public static class SimulationSettingKeys
{
// --- Logging Channels ---
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> SimulationChannel = new("Logging.Channel.Simulation", true);
public static readonly SettingKey<bool> MatrixChannel = new("Logging.Channel.Matrix", true);
// --- Simulation & Fee Defaults ---
public static readonly SettingKey<decimal> DefaultSlippagePercent = new("Simulation.DefaultSlippagePercent", 0.05m); // 0.05%
public static readonly SettingKey<decimal> DefaultOrderFeeEur = new("Simulation.DefaultOrderFeeEur", 1.00m); // 1.00 € pro Order
public static readonly SettingKey<decimal> DefaultStartingCapital = new("Simulation.DefaultStartingCapital", 10000m);
public static readonly SettingKey<int> MinSampleTradesForApproval = new("Simulation.MinSampleTradesForApproval", 5);
public static readonly SettingKey<decimal> HighProfitFactorThreshold = new("Simulation.HighProfitFactorThreshold", 1.60m);
public static readonly SettingKey<decimal> LowProfitFactorThreshold = new("Simulation.LowProfitFactorThreshold", 1.00m);
/// <summary>
/// Simulated knock-out derivative barrier distance below (long) / above (short) the strategy's stop-loss,
/// as a percent. Was previously a hardcoded 2% (0.98/1.02 multiplier) in <c>VirtualBacktestBroker</c>.
/// </summary>
public static readonly SettingKey<decimal> KnockOutBarrierBufferPercent = new("Simulation.KnockOutBarrierBufferPercent", 2.0m);
/// <summary>
/// Fallback trailing-stop distance (as a percent of the current close) used once a position's TP1 has
/// been hit, for any <c>TrailingStopRule.Type</c> other than <see cref="FinlyticCore.Dtos.TechnicalAnalysis.TrailingStopType.AtrMultiplier"/>
/// (which is instead simulated honestly via that rule's own ATR multiplier - see <c>VirtualBacktestBroker.UpdateActivePositions</c>).
/// <c>SuperTrendLine</c>/<c>SwingPoints</c> rules would require recomputing that live indicator on every
/// backtest bar, which this broker does not have the inputs for; this flat, configurable percent is an
/// explicit, documented approximation for those two rule types rather than silently reusing the ATR
/// multiplier's numeric value for an unrelated rule type (the previous hardcoded behavior).
/// </summary>
public static readonly SettingKey<decimal> DefaultTrailingStopPercent = new("Simulation.DefaultTrailingStopPercent", 3.0m);
/// <summary>
/// Whether <c>ReliabilityMatrixRecomputeBackgroundService</c> periodically re-runs backtests for every
/// (Isin, StrategyKey, Timeframe) combination already present in the reliability matrix, instead of that
/// data only ever being refreshed when a human happens to manually re-run the same backtest.
/// </summary>
public static readonly SettingKey<bool> EnableScheduledMatrixRecompute = new("Simulation.EnableScheduledMatrixRecompute", true);
/// <summary>
/// How old a reliability-matrix row (<c>SimulationStrategyMatrixEntity.UpdatedAtUtc</c>) must be before
/// <c>ReliabilityMatrixRecomputeBackgroundService</c> refreshes it again.
/// </summary>
public static readonly SettingKey<int> MatrixRecomputeIntervalHours = new("Simulation.MatrixRecomputeIntervalHours", 24);
/// <summary>How often <c>ReliabilityMatrixRecomputeBackgroundService</c> checks for stale matrix rows.</summary>
public static readonly SettingKey<int> MatrixRecomputeCheckIntervalMinutes = new("Simulation.MatrixRecomputeCheckIntervalMinutes", 60);
}
@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticSimulation.Services;
using FinlyticSimulation.Services.Mqtt;
using FinlyticSimulation.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticSimulation.Util;
public class SimulationMqttClient : ManagedMqttClient, IHostedService, ISimulationRpcClient
{
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SimulationMqttClient> _logger;
public SimulationMqttClient(
ILogger<SimulationMqttClient> logger,
IConfiguration configuration,
IServiceScopeFactory scopeFactory) : base(logger)
{
_logger = logger;
_configuration = configuration;
_scopeFactory = scopeFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticSimulation");
_logger.LogInformation("Starting FinlyticSimulation MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping FinlyticSimulation MQTT client.");
await DisconnectAsync();
}
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("FinlyticSimulation MQTT client connected. Registering RPC endpoints...");
await SubscribeAsync(MqttTopics.ResponseWildcard);
await SubscribeRpcAsync<BacktestRequestDto, BacktestReportDto>(MqttTopics.RequestFilter(MqttTopics.Channels.SimRunBacktest), HandleRunBacktestRpcAsync);
await SubscribeRpcAsync<GetReliabilityRequest, StrategyAssetReliabilityDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetReliability), HandleGetReliabilityRpcAsync);
await SubscribeRpcAsync<IsinRequest, List<StrategyAssetReliabilityDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetMatrixForAsset), HandleGetMatrixRpcAsync);
await SubscribeRpcAsync<GetBacktestHistoryRequest, List<BacktestHistoryEntryDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetBacktestHistory), HandleGetBacktestHistoryRpcAsync);
await SubscribeRpcAsync<GetBacktestRunDetailRequest, BacktestReportDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetBacktestRunDetail), HandleGetBacktestRunDetailRpcAsync);
await SubscribeRpcAsync<GetStrategyParametersRequest, StrategyParameterProfileDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetStrategyParameters), HandleGetStrategyParametersRpcAsync);
await SubscribeRpcAsync<SaveStrategyParametersRequest, StrategyParameterProfileDto>(MqttTopics.RequestFilter(MqttTopics.Channels.SimSaveStrategyParameters), HandleSaveStrategyParametersRpcAsync);
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.SimSettingsGetAll), HandleSettingsGetAllRpcAsync);
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.SimSettingsUpdate), HandleSettingsUpdateRpcAsync);
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync);
FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticSimulation", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync(MqttTopics.Logs("FinlyticSimulation"), logDto);
}
};
}
private async Task<BacktestReportDto> HandleRunBacktestRpcAsync(BacktestRequestDto? req, string correlationId)
{
if (req == null) throw new ArgumentNullException(nameof(req));
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SimulationMqttClient>>();
await logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
"[SimulationMqttClient] Processing RPC sim_RunBacktest for {Isin} ({Strategy}) [CorrelationId: {CorrelationId}]",
req.Isin, req.StrategyKey, correlationId);
return await simEngine.RunBacktestAsync(req);
}
private async Task<StrategyAssetReliabilityDto?> HandleGetReliabilityRpcAsync(GetReliabilityRequest? req, string correlationId)
{
if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return null;
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
return await simEngine.GetStrategyReliabilityAsync(req.Isin, req.StrategyKey, req.Timeframe);
}
private async Task<List<StrategyAssetReliabilityDto>> HandleGetMatrixRpcAsync(IsinRequest? req, string correlationId)
{
if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return [];
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
return await simEngine.GetMatrixForAssetAsync(req.Isin);
}
private async Task<List<BacktestHistoryEntryDto>> HandleGetBacktestHistoryRpcAsync(GetBacktestHistoryRequest? req, string correlationId)
{
if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return [];
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
return await simEngine.GetBacktestHistoryAsync(req);
}
private async Task<BacktestReportDto?> HandleGetBacktestRunDetailRpcAsync(GetBacktestRunDetailRequest? req, string correlationId)
{
if (req == null || req.RunId == Guid.Empty) return null;
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
return await simEngine.GetBacktestRunDetailAsync(req.RunId);
}
private async Task<StrategyParameterProfileDto?> HandleGetStrategyParametersRpcAsync(GetStrategyParametersRequest? req, string correlationId)
{
if (req == null || string.IsNullOrWhiteSpace(req.Isin) || string.IsNullOrWhiteSpace(req.StrategyKey)) return null;
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
return await simEngine.GetStrategyParametersAsync(req.Isin, req.StrategyKey);
}
private async Task<StrategyParameterProfileDto> HandleSaveStrategyParametersRpcAsync(SaveStrategyParametersRequest? req, string correlationId)
{
if (req == null || string.IsNullOrWhiteSpace(req.Isin) || string.IsNullOrWhiteSpace(req.StrategyKey))
{
throw new ArgumentException("Isin and StrategyKey are required to save a parameter profile.");
}
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
return await simEngine.SaveStrategyParametersAsync(req.Isin, req.StrategyKey, req.Parameters ?? new Dictionary<string, decimal>());
}
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SimulationSettingKeys) });
}
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
}
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SimulationSettingKeys) });
}
private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId)
{
if (topic.Contains("FinlyticSimulation", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase))
{
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticSimulation", "Online", DateTime.UtcNow, "Connected"));
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SimulationMqttClient>>();
await logger.LogInfoAsync(SimulationSettingKeys.HealthPingChannel,
"[FinlyticSimulation] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=finlytic_simulation;Username=postgres;Password=postgres"
},
"MQTT": {
"Host": "localhost",
"Port": 1883,
"ClientId": "finlytic_simulation"
}
}