feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+191
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user