feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests

This commit is contained in:
2026-08-24 21:37:05 +02:00
parent a4959658a2
commit 5c95dd182c
49 changed files with 7709 additions and 0 deletions
@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticEngine.Services.Scoring;
using FinlyticEngine.Settings;
using Microsoft.Extensions.Configuration;
namespace FinlyticEngine.Services.Ai;
public class AiReasoningGateService : IAiReasoningGateService
{
/// <summary>
/// Self-contained fallback framing sent as part of every request's <c>instructions</c> field: role, the
/// four things the validator must actually weigh, the exact expected JSON response schema, and a
/// fail-closed default. Kept here (not only in n8n's own system prompt) so validation still behaves
/// sensibly even if n8n's system prompt is ever left empty/misconfigured - defense in depth, not reliance
/// on a single external configuration surface.
/// </summary>
private const string BaseInstructions =
"Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System. " +
"Bewerte, ob das folgende technische Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " +
"insbesondere: (1) Widersprechen sich technisches Signal, Sentiment-Lage und Fundamentaldaten? " +
"(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen bevorstehenden, schwer " +
"kalkulierbaren Kurssprung hin? (3) Was sagt die Backtest-Historie (falls vorhanden) über die " +
"Zuverlässigkeit dieser Strategie für genau dieses Asset? (4) Passt das Risk/Reward-Verhältnis zum " +
"aktuellen Markt-Regime? Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem " +
"Schema, ohne Text davor oder danach: {\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), " +
"\"thesisSummary\": string, \"invalidationReason\": string, \"keyCatalysts\": string[], " +
"\"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " +
"ist günstiger als ein falscher.";
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<AiReasoningGateService> _finlyticLogger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public AiReasoningGateService(
HttpClient httpClient,
IConfiguration configuration,
ISettingsService settingsService,
IFinlyticLogger<AiReasoningGateService> finlyticLogger)
{
_httpClient = httpClient;
_configuration = configuration;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task<AiValidationResultDto> ValidateOpportunityAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
{
var minCompositeScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken);
var enableAi = await _settingsService.GetSettingAsync(EngineSettingKeys.EnableAiValidation, cancellationToken);
if (!enableAi)
{
// Deterministic Fast-Pass: rein regelbasiert, keine KI beteiligt.
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
"[Regelbasiert] AI-Validierungs-Gate ist deaktiviert (Fast-Pass Modus).");
}
var webhookUrl = _configuration["Ai:N8nValidationWebhookUrl"] ?? "https://n8n.kleidukos.me/webhook/trade-validation";
var timeoutSeconds = await _settingsService.GetSettingAsync(EngineSettingKeys.AiValidationTimeoutSeconds, cancellationToken);
try
{
var payload = BuildRequestPayload(setup, sentiment, fundamentals, score, reliability);
var jsonContent = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
await _finlyticLogger.LogInfoAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] Sending AI validation request for ISIN {Isin} to {Url}", setup.Isin, webhookUrl);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)));
var response = await _httpClient.PostAsync(webhookUrl, jsonContent, cts.Token);
if (response.IsSuccessStatusCode)
{
var responseJson = await response.Content.ReadAsStringAsync(cts.Token);
var aiResult = ParseN8nValidationResponse(responseJson);
// Defense in depth, independent of the specific mapping above: System.Text.Json does not
// throw when a JSON object's property names match none of AiValidationResultDto's - it just
// builds the record from parameter defaults (ThesisSummary=null, IsApproved=false, ...), which
// then LOOKS like a real, successfully-parsed AI result even though nothing was actually
// extracted. That previously reached SaveChangesAsync with a null ThesisSummary and crashed on
// the NOT NULL constraint on engine_evaluation_snapshots.AiThesisSummary. Treat a result with
// no usable thesis exactly like "no usable JSON at all", regardless of why parsing came up
// empty (missing field, webhook contract drift, malformed nesting, ...).
if (aiResult != null && !string.IsNullOrWhiteSpace(aiResult.ThesisSummary))
{
// Herkunft ist immer echte KI, unabhängig davon, ob der Webhook das Feld selbst setzt.
aiResult = aiResult with { Source = ValidationSource.Ai };
await _finlyticLogger.LogInfoAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] AI validation result for {Isin}: Approved={Approved}, Confidence={Conf}",
setup.Isin, aiResult.IsApproved, aiResult.Confidence?.ToString("F2") ?? "n/a");
return aiResult;
}
await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] Webhook antwortete mit Status {Status} für ISIN {Isin}, lieferte aber kein verwertbares JSON-Ergebnis (RawResponse={RawResponse}). Regelbasierter Fallback.",
response.StatusCode, setup.Isin, responseJson);
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
"[Regelbasiert] KI-Webhook antwortete erfolgreich, aber ohne verwertbares Ergebnis - automatische Freigabe basierend auf technischer und Sentiment-Confluence.");
}
// Webhook wurde erreicht, hat die Anfrage aber explizit mit einem Fehlerstatus abgelehnt.
await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] Webhook lehnte Validierungsanfrage für ISIN {Isin} mit Status {Status} ab. Regelbasierter Fallback.",
setup.Isin, response.StatusCode);
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
$"[Regelbasiert] KI-Webhook hat die Anfrage mit Status {(int)response.StatusCode} abgelehnt - automatische Freigabe basierend auf technischer und Sentiment-Confluence.");
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
if (cancellationToken.IsCancellationRequested)
{
// Echter Abbruch durch den Aufrufer, kein Webhook-Problem - nicht als Fachfehler verschlucken.
throw;
}
// Webhook war innerhalb des Timeouts nicht erreichbar (Netzwerkfehler/Timeout).
await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel, ex,
"[AiReasoningGate] KI-Webhook für ISIN {Isin} nicht erreichbar (Timeout/Netzwerkfehler). Regelbasierter Fallback.", setup.Isin);
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
"[Regelbasiert] KI-Webhook war nicht erreichbar (Timeout/Netzwerkfehler) - automatische Freigabe basierend auf technischer und Sentiment-Confluence.");
}
}
/// <summary>
/// Assembles the full, richly-contextualized request payload sent to n8n - every signal already computed
/// elsewhere in the evaluation pipeline (sub-scores, detected patterns/indicators, market regime, why the
/// ISIN was being watched, backtest reliability, raw fundamentals/events) rather than only the bare
/// composite score the previous payload sent (Rules.md §4: every field here is a real, already-computed
/// value - nothing is invented for the AI's benefit).
/// </summary>
private static N8nValidationRequestPayload BuildRequestPayload(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
StrategyAssetReliabilityDto? reliability)
{
var takeProfit1 = setup.ExitPlan.TakeProfitStages.Count > 0
? setup.ExitPlan.TakeProfitStages[0].TargetPrice
: setup.EntryPrice * 1.05m;
var technicalSetup = new N8nTechnicalSetupSection(
Strategy: setup.StrategyKey,
StrategyName: setup.StrategyName,
Direction: setup.Direction.ToString(),
Entry: setup.EntryPrice,
StopLoss: setup.InvalidationPrice,
TakeProfit1: takeProfit1,
RiskRewardRatio: setup.EstimatedRiskRewardRatio,
QualityScore: setup.QualityScore,
Rationale: setup.TechnicalRationale,
MarketRegime: setup.Regime?.ToString(),
TriggeringPatterns: setup.TriggeringPatterns.ConvertAll(p =>
new N8nPatternSection(p.Type.ToString(), p.Bias.ToString(), p.QualityScore, p.Description)),
IndicatorSnapshot: setup.IndicatorSnapshot
);
var watchlistContext = setup.UniverseSource.HasValue && setup.UniverseEnteredAtUtc.HasValue
? new N8nWatchlistContextSection(setup.UniverseSource.Value.ToString(), setup.UniverseEnteredAtUtc.Value)
: null;
var sentimentSection = new N8nSentimentSection(
Label: sentiment?.CurrentSummary?.SentimentLabel ?? "NEUTRAL",
WeightedScore: sentiment?.CurrentSummary?.CompoundScore ?? 0.0,
Trend: sentiment?.CurrentSummary?.Trend ?? "STABLE",
LatestHighlight: sentiment?.CurrentSummary?.KeyHighlight ?? "Keine aktuellen News-Highlights"
);
var fund = fundamentals?.Fundamentals;
decimal? operatingMarginPercent = fund?.OperatingIncome.HasValue == true && fund.TotalRevenue is > 0
? Math.Round(fund.OperatingIncome!.Value / fund.TotalRevenue!.Value * 100m, 2)
: null;
var fundamentalsSection = new N8nFundamentalsSection(
ForwardPe: fund?.ForwardPe,
OperatingMarginPercent: operatingMarginPercent,
RevenueGrowthYoYRatio: fund?.RevenueGrowthYoY,
ReturnOnEquityRatio: fund?.ReturnOnEquity,
DebtToEquity: fund?.DebtToEquity,
FreeCashFlow: fund?.FreeCashFlow,
ConsensusRating: fund?.ConsensusRating,
PriceTargetMean: fund?.PriceTargetMean,
ShortPercentOfFloatRatio: fund?.ShortPercentOfFloat,
DaysToEarnings: score.DaysToNextEarnings,
PassedEarningsLockout: score.PassedEarningsLockout,
DaysToNextExDividend: score.DaysToNextExDividend,
PassedDividendGate: score.PassedDividendGate
);
var reliabilitySection = reliability == null
? null
: new N8nReliabilitySection(
ReliabilityScore: reliability.ReliabilityScore,
WinRatePercent: reliability.WinRatePercent,
ProfitFactor: reliability.ProfitFactor,
SampleTradeCount: reliability.SampleTradeCount,
IsStrategyApprovedForAsset: reliability.IsStrategyApprovedForAsset,
RecommendedAction: reliability.RecommendedAction);
var scoreBreakdown = new N8nScoreBreakdownSection(
CompositeScore: score.CompositeScore,
TechnicalScore: score.TechnicalScore,
SentimentScore: score.SentimentScore,
FundamentalScore: score.FundamentalScore,
ReliabilityBonus: score.ReliabilityBonus
);
return new N8nValidationRequestPayload(
Instructions: BaseInstructions,
Asset: new N8nAssetSection(setup.Isin, setup.Symbol, setup.CurrentPrice),
TechnicalSetup: technicalSetup,
WatchlistContext: watchlistContext,
Sentiment: sentimentSection,
Fundamentals: fundamentalsSection,
Reliability: reliabilitySection,
ScoreBreakdown: scoreBreakdown
);
}
/// <summary>
/// Deserialisiert die Antwort des n8n-Validierungs-Webhooks. Erwartet dieselbe Feldbenennung wie
/// <see cref="AiValidationResultDto"/> selbst (camelCase <c>isApproved</c>/<c>thesisSummary</c>/...) statt
/// eines separaten, undokumentierten Vokabulars - und kann sowohl als einzelnes JSON-Objekt als auch -
/// wie vom n8n "Respond to Webhook"-Knoten bei "All Incoming Items" üblich - als Array mit einem Element
/// eintreffen. Nur real im Payload vorhandene Felder fließen ein (Rules.md §4).
/// </summary>
/// <returns>
/// <see langword="null"/>, wenn der Payload syntaktisch kein JSON-Objekt (bzw. Array mit einem Objekt als
/// erstem Element) ist, oder wenn <c>isApproved</c>/<c>thesisSummary</c> - die zwei Felder, ohne die kein
/// verwertbares Ergebnis vorliegt - fehlen.
/// </returns>
private static AiValidationResultDto? ParseN8nValidationResponse(string responseJson)
{
JsonElement root;
try
{
root = JsonSerializer.Deserialize<JsonElement>(responseJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
var element = root.ValueKind switch
{
JsonValueKind.Array => root.GetArrayLength() > 0 ? root[0] : (JsonElement?)null,
JsonValueKind.Object => root,
_ => null
};
if (element is not { ValueKind: JsonValueKind.Object } obj)
{
return null;
}
N8nValidationResponsePayload? payload;
try
{
payload = obj.Deserialize<N8nValidationResponsePayload>(JsonOptions);
}
catch (JsonException)
{
return null;
}
if (payload?.IsApproved is null || string.IsNullOrWhiteSpace(payload.ThesisSummary))
{
// Nothing usable: either malformed JSON, or the validator didn't answer the two things that
// matter most (a clear yes/no and a reason). The caller treats this identically to "no usable
// JSON at all" (Rules.md §4: a partially-empty response must never masquerade as a real verdict).
return null;
}
return new AiValidationResultDto(
IsApproved: payload.IsApproved.Value,
Confidence: payload.Confidence,
Source: ValidationSource.Ai,
ThesisSummary: payload.ThesisSummary,
InvalidationReason: payload.InvalidationReason ?? payload.ThesisSummary,
KeyCatalysts: payload.KeyCatalysts ?? new List<string>(),
IdentifiedRisks: payload.IdentifiedRisks ?? new List<string>()
);
}
/// <summary>
/// Erstellt eine regelbasierte Freigabe-/Ablehnungsentscheidung, wenn keine echte KI-Bewertung
/// vorliegt (Gate deaktiviert, Webhook nicht erreichbar oder Webhook liefert kein verwertbares
/// Ergebnis). Die Entscheidung selbst (<see cref="ScoringResult.CompositeScore"/>,
/// <see cref="ScoringResult.PassedEarningsLockout"/> und <see cref="ScoringResult.PassedDividendGate"/>)
/// ist legitime regelbasierte Geschäftslogik, aber es wird bewusst KEINE Konfidenz erfunden (Rules.md §4)
/// und die Herkunft wird explizit als <see cref="ValidationSource.RuleBased"/> markiert, damit
/// Frontend/Logs sie nicht mit einer echten KI-These verwechseln.
/// </summary>
/// <param name="minCompositeScore">
/// <see cref="EngineSettingKeys.MinCompositeScore"/> - reused here instead of a second, independently
/// hardcoded threshold, so the rule-based fallback's bar for approval always matches the real score gate
/// the AI-backed path is gated by (previously duplicated as a separate literal <c>70.0m</c>).
/// </param>
private static AiValidationResultDto CreateRuleBasedResult(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
ScoringResult score,
decimal minCompositeScore,
string summary)
{
var catalysts = new List<string>
{
$"Technisches Signal '{setup.StrategyName}' mit Quality-Score {setup.QualityScore:F1}",
sentiment?.CurrentSummary != null ? $"Sentiment: {sentiment.CurrentSummary.SentimentLabel} (Trend: {sentiment.CurrentSummary.Trend})" : "Neutrales Marktumfeld"
};
var risks = new List<string>
{
$"Invalidierung bei {setup.InvalidationPrice:F2} €",
score.DaysToNextEarnings.HasValue ? $"Nächste Quartalszahlen in {score.DaysToNextEarnings.Value} Tagen" : "Allgemeine Marktvolatilität"
};
if (score.DaysToNextExDividend.HasValue)
{
risks.Add($"Nächster Ex-Dividenden-Tag in {score.DaysToNextExDividend.Value} Tag(en)");
}
return new AiValidationResultDto(
IsApproved: score.CompositeScore >= minCompositeScore && score.PassedEarningsLockout && score.PassedDividendGate,
Confidence: null,
Source: ValidationSource.RuleBased,
ThesisSummary: summary,
InvalidationReason: $"Schlusskurs unter {setup.InvalidationPrice:F2} € invalidiert das Setup.",
KeyCatalysts: catalysts,
IdentifiedRisks: risks
);
}
}
@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Services.Scoring;
namespace FinlyticEngine.Services.Ai;
public interface IAiReasoningGateService
{
/// <summary>
/// Validiert ein technisches Setup entweder über den konfigurierten KI-Webhook oder, falls das
/// Gate deaktiviert ist bzw. der Webhook nicht verfügbar ist, über eine regelbasierte
/// Ersatzentscheidung. Das Ergebnis kennzeichnet über <see cref="AiValidationResultDto.Source"/>
/// eindeutig, welcher der beiden Fälle vorliegt.
/// </summary>
/// <param name="reliability">
/// FinlyticSimulation's backtest-reliability verdict for this exact (Isin, StrategyKey) combination, if
/// one has ever been computed (see <c>QuantSimulationEngine</c>) - forwarded onto the AI payload so the
/// model sees the same win-rate/profit-factor evidence <see cref="ICompositeOpportunityScorer"/> already
/// used for its bonus/veto. <see langword="null"/> when nobody has ever run a backtest for this
/// combination yet (never fabricated, Rules.md §4).
/// </param>
Task<AiValidationResultDto> ValidateOpportunityAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
namespace FinlyticEngine.Services.Ai;
/// <summary>
/// Outgoing request body for the n8n AI trade-validation webhook. Service-local (not FinlyticCore): this
/// shape is an integration detail of <see cref="AiReasoningGateService"/> only, not a cross-service MQTT
/// contract (Rules.md §3 - core placement applies to data shared across services, not to a single service's
/// own outbound HTTP integration). Serialized with <c>JsonNamingPolicy.CamelCase</c>, so every property here
/// reaches n8n as camelCase without needing per-property <c>[JsonPropertyName]</c> attributes.
/// </summary>
public record N8nValidationRequestPayload(
string Instructions,
N8nAssetSection Asset,
N8nTechnicalSetupSection TechnicalSetup,
N8nWatchlistContextSection? WatchlistContext,
N8nSentimentSection Sentiment,
N8nFundamentalsSection Fundamentals,
N8nReliabilitySection? Reliability,
N8nScoreBreakdownSection ScoreBreakdown
);
public record N8nAssetSection(string Isin, string Symbol, decimal CurrentPrice);
public record N8nPatternSection(string Type, string Bias, decimal QualityScore, string Description);
public record N8nTechnicalSetupSection(
string Strategy,
string StrategyName,
string Direction,
decimal Entry,
decimal StopLoss,
decimal TakeProfit1,
decimal RiskRewardRatio,
decimal QualityScore,
string Rationale,
string? MarketRegime,
List<N8nPatternSection> TriggeringPatterns,
Dictionary<string, decimal> IndicatorSnapshot
);
/// <summary>Why FinlyticTechnicals was even scanning this ISIN (see <c>TechnicalUniverseManager</c>).</summary>
public record N8nWatchlistContextSection(string Source, DateTime EnteredAtUtc);
public record N8nSentimentSection(string Label, double WeightedScore, string Trend, string LatestHighlight);
/// <summary>
/// Fundamental data + the two temporal suppression gates (<see cref="Scoring.ScoringResult.PassedEarningsLockout"/>/
/// <see cref="Scoring.ScoringResult.PassedDividendGate"/>). Ratio fields (<c>ReturnOnEquityRatio</c> etc.) are
/// forwarded as raw fractions (e.g. <c>0.15</c> = 15%) exactly as stored, rather than guessing a ×100
/// conversion that could silently misrepresent the source data (Rules.md §4). <see cref="OperatingMarginPercent"/>
/// is the one exception: an honest, explicitly computed ratio (OperatingIncome / TotalRevenue × 100), only
/// populated when both inputs are real numbers.
/// </summary>
public record N8nFundamentalsSection(
decimal? ForwardPe,
decimal? OperatingMarginPercent,
decimal? RevenueGrowthYoYRatio,
decimal? ReturnOnEquityRatio,
decimal? DebtToEquity,
decimal? FreeCashFlow,
string? ConsensusRating,
decimal? PriceTargetMean,
decimal? ShortPercentOfFloatRatio,
int? DaysToEarnings,
bool PassedEarningsLockout,
int? DaysToNextExDividend,
bool PassedDividendGate
);
/// <summary>FinlyticSimulation's backtest verdict for this exact (Isin, StrategyKey) - see <see cref="IAiReasoningGateService"/>.</summary>
public record N8nReliabilitySection(
decimal ReliabilityScore,
decimal WinRatePercent,
decimal ProfitFactor,
int SampleTradeCount,
bool IsStrategyApprovedForAsset,
string RecommendedAction
);
public record N8nScoreBreakdownSection(
decimal CompositeScore,
decimal TechnicalScore,
decimal SentimentScore,
decimal FundamentalScore,
decimal ReliabilityBonus
);
/// <summary>
/// Expected shape of a successful n8n webhook response - deliberately identical field-for-field to
/// <see cref="FinlyticCore.Dtos.Trading.AiValidationResultDto"/> (camelCase JSON) instead of the previous,
/// undocumented ad hoc vocabulary (<c>status</c>/<c>action_recommendation</c>/<c>raw_validation_result.*</c>)
/// that no prompt ever actually specified. <see cref="Confidence"/> is nullable because a validator that
/// declines to give a numeric confidence must not have one fabricated for it (Rules.md §4).
/// </summary>
public record N8nValidationResponsePayload(
bool? IsApproved,
decimal? Confidence,
string? ThesisSummary,
string? InvalidationReason,
List<string>? KeyCatalysts,
List<string>? IdentifiedRisks
);
@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticEngine.Services.Derivatives;
public interface IKnockOutDerivativeResolver
{
Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
string underlyingIsin,
SignalDirection direction,
decimal chartStopLoss,
decimal currentPrice,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Assets;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Models.Assets;
using FinlyticCore.Services;
using FinlyticEngine.Services.Mqtt;
using FinlyticEngine.Settings;
namespace FinlyticEngine.Services.Derivatives;
public class KnockOutDerivativeResolver : IKnockOutDerivativeResolver
{
private readonly IEngineRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<KnockOutDerivativeResolver> _logger;
public KnockOutDerivativeResolver(
IEngineRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<KnockOutDerivativeResolver> logger)
{
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
public async Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
string underlyingIsin,
SignalDirection direction,
decimal chartStopLoss,
decimal currentPrice,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(underlyingIsin) || chartStopLoss <= 0 || currentPrice <= 0)
{
return null;
}
var optionType = direction == SignalDirection.Buy ? "long" : "short";
var minLeverage = await _settingsService.GetSettingAsync(EngineSettingKeys.MinDerivativeLeverage, cancellationToken);
var targetDefaultLeverage = await _settingsService.GetSettingAsync(EngineSettingKeys.TargetDefaultLeverage, cancellationToken);
var safetyBufferPercent = await _settingsService.GetSettingAsync(EngineSettingKeys.KnockOutSafetyBufferPercent, cancellationToken);
try
{
var req = new GetDerivativesRequest(
UnderlyingIsin: underlyingIsin,
OptionType: optionType,
TargetLeverage: targetDefaultLeverage,
After: null,
Page: 0,
ForceRefresh: false
);
await _logger.LogInfoAsync(EngineSettingKeys.DerivativesChannel,
"[KnockOutResolver] Requesting derivatives for {Isin} ({OptionType}, target leverage {TargetLev})",
underlyingIsin, optionType, targetDefaultLeverage);
var derivatives = await _rpcClient.SendRpcRequestAsync<List<DerivativeDto>, GetDerivativesRequest>(
"assets_GetDerivatives",
req,
TimeSpan.FromSeconds(5)
);
if (derivatives == null || derivatives.Count == 0)
{
await _logger.LogWarningAsync(EngineSettingKeys.DerivativesChannel,
"[KnockOutResolver] No derivatives returned from FinlyticAssets for {Isin}", underlyingIsin);
return null;
}
// Hard Knock-Out Safety Check
var safeDerivatives = derivatives.Where(d =>
{
if (d.Leverage < minLeverage || d.Barrier <= 0) return false;
if (direction == SignalDirection.Buy)
{
// For Long: Knock-Out Barrier MUST be at or below (StopLoss - Buffer%)
decimal maxAllowedBarrier = chartStopLoss * (1.0m - (safetyBufferPercent / 100.0m));
return d.Barrier <= maxAllowedBarrier;
}
else
{
// For Short: Knock-Out Barrier MUST be at or above (StopLoss + Buffer%)
decimal minAllowedBarrier = chartStopLoss * (1.0m + (safetyBufferPercent / 100.0m));
return d.Barrier >= minAllowedBarrier;
}
}).ToList();
if (safeDerivatives.Count == 0)
{
await _logger.LogWarningAsync(EngineSettingKeys.DerivativesChannel,
"[KnockOutResolver] None of the {Count} derivatives passed the hard KO safety buffer ({Buffer}%) for ISIN {Isin} (SL: {SL})",
derivatives.Count, safetyBufferPercent, underlyingIsin, chartStopLoss);
return null;
}
// Ranking: 1. Issuer Rank, 2. Closeness to target leverage
var best = safeDerivatives
.OrderBy(d => GetIssuerRank(d.Issuer))
.ThenBy(d => Math.Abs(d.Leverage - targetDefaultLeverage))
.First();
decimal calculatedBuffer = direction == SignalDirection.Buy
? ((chartStopLoss - best.Barrier) / chartStopLoss) * 100.0m
: ((best.Barrier - chartStopLoss) / chartStopLoss) * 100.0m;
// Trade Republic liefert für Derivate keine WKN (nur ISIN, siehe DerivativeDto/
// TradeRepublicDerivativeItemDto). Die ISIN darf nicht als WKN ausgegeben werden,
// da beide unterschiedliche Wertpapierkennungen sind (Rules.md §4) - daher null statt Fake-Wert.
var result = new DerivativeSelectionDto(
DerivativeIsin: best.Isin,
DerivativeWkn: null,
Issuer: best.Issuer ?? "Unknown",
OptionType: optionType.ToUpperInvariant(),
Strike: best.Strike,
Barrier: best.Barrier,
Leverage: best.Leverage,
SafetyBufferPercent: Math.Round(calculatedBuffer, 2),
SpreadPercentage: 0m,
Size: best.Size ?? 0.1m
);
await _logger.LogInfoAsync(EngineSettingKeys.DerivativesChannel,
"[KnockOutResolver] Selected optimal turbo {DerivIsin} for {Isin}: Lev={Lev}x, Barrier={Barrier}, Buffer={Buffer:F1}%, Issuer={Issuer}",
result.DerivativeIsin, underlyingIsin, result.Leverage, result.Barrier, result.SafetyBufferPercent, result.Issuer);
return result;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(EngineSettingKeys.DerivativesChannel, ex,
"[KnockOutResolver] Failed to resolve derivative for ISIN {Isin}", underlyingIsin);
return null;
}
}
private static int GetIssuerRank(string? issuer)
{
if (string.IsNullOrWhiteSpace(issuer)) return 5;
var s = issuer.ToUpperInvariant();
if (s.Contains("HSBC")) return 1;
if (s.Contains("SOCIETE") || s.Contains("SG")) return 2;
if (s.Contains("BNP")) return 3;
if (s.Contains("UBS") || s.Contains("CITI") || s.Contains("VONTOBEL")) return 4;
return 5;
}
}
@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace FinlyticEngine.Services.Mqtt;
public interface IEngineRpcClient
{
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,167 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticEngine.Settings;
namespace FinlyticEngine.Services.Scoring;
public class CompositeOpportunityScorer : ICompositeOpportunityScorer
{
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<CompositeOpportunityScorer> _logger;
public CompositeOpportunityScorer(
ISettingsService settingsService,
IFinlyticLogger<CompositeOpportunityScorer> logger)
{
_settingsService = settingsService;
_logger = logger;
}
public async Task<ScoringResult> CalculateCompositeScoreAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
{
var wTech = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightTechnical, cancellationToken);
var wSent = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightSentiment, cancellationToken);
var wFund = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightFundamental, cancellationToken);
var lockoutDays = await _settingsService.GetSettingAsync(EngineSettingKeys.EarningsLockoutDays, cancellationToken);
var dividendGateDays = await _settingsService.GetSettingAsync(EngineSettingKeys.DividendGateDays, cancellationToken);
// 1. Technical Score (0..100)
decimal sTech = Math.Clamp(setup.QualityScore, 0m, 100m);
// 2. Sentiment Score (0..100)
decimal sSent = 50m;
if (sentiment?.CurrentSummary != null)
{
decimal compound = (decimal)sentiment.CurrentSummary.CompoundScore; // -1.0 .. +1.0
if (setup.Direction == SignalDirection.Buy)
{
// Compound: -1.0 -> 0, 0.0 -> 50, +1.0 -> 100
sSent = Math.Clamp(((compound + 1.0m) / 2.0m) * 100m, 0m, 100m);
}
else if (setup.Direction == SignalDirection.Sell)
{
// Compound: +1.0 -> 0, 0.0 -> 50, -1.0 -> 100
sSent = Math.Clamp(((1.0m - compound) / 2.0m) * 100m, 0m, 100m);
}
}
// 3. Fundamental Score (0..100)
decimal sFund = 50m;
if (fundamentals?.Fundamentals != null)
{
var fund = fundamentals.Fundamentals;
decimal baseScore = 50m;
// Fwd PE evaluation
if (fund.ForwardPe.HasValue && fund.ForwardPe.Value > 0)
{
if (fund.ForwardPe.Value < 20m) baseScore += 10m;
else if (fund.ForwardPe.Value > 45m) baseScore -= 10m;
}
// Return on Equity evaluation
if (fund.ReturnOnEquity.HasValue)
{
if (fund.ReturnOnEquity.Value > 0.15m) baseScore += 10m;
else if (fund.ReturnOnEquity.Value < 0.0m) baseScore -= 15m;
}
// Analyst rating
if (!string.IsNullOrWhiteSpace(fund.ConsensusRating))
{
var r = fund.ConsensusRating.ToLowerInvariant();
if (r.Contains("buy") || r.Contains("strong_buy") || r.Contains("outperform")) baseScore += 10m;
else if (r.Contains("sell") || r.Contains("underperform")) baseScore -= 15m;
}
sFund = Math.Clamp(baseScore, 0m, 100m);
}
// 4. Earnings Lockout Check
int? daysToEarnings = fundamentals?.DaysToNextEarnings;
bool passedLockout = true;
decimal mEarnings = 1.0m;
if (daysToEarnings.HasValue && daysToEarnings.Value <= lockoutDays && daysToEarnings.Value >= 0)
{
passedLockout = false;
mEarnings = 0.15m; // Strong suppression penalty
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorer] ISIN {Isin} hit earnings lockout ({Days} days to earnings). Suppressing score.",
setup.Isin, daysToEarnings.Value);
}
// 4b. Dividend Gate Check - moderate suppression around the ex-dividend date. Milder than the earnings
// lockout above (mDividend = 0.5 vs. mEarnings = 0.15) because an ex-dividend price adjustment is a
// predictable, mechanical gap-down roughly equal to the dividend amount, not a fundamental surprise -
// but it still distorts technical patterns/indicators enough to warrant caution, not a hard veto.
int? daysToExDividend = fundamentals?.DaysToNextExDividend;
bool passedDividendGate = true;
decimal mDividend = 1.0m;
if (daysToExDividend.HasValue && daysToExDividend.Value <= dividendGateDays && daysToExDividend.Value >= 0)
{
passedDividendGate = false;
mDividend = 0.5m; // Moderate suppression penalty - milder than earnings/simulation-veto
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorer] ISIN {Isin} hit dividend gate ({Days} days to ex-dividend). Suppressing score.",
setup.Isin, daysToExDividend.Value);
}
// 5. Backtesting Matrix Feedback-Loop (Score-Bonus or Veto)
decimal matrixBonus = 0m;
bool passedVeto = true;
decimal mVeto = 1.0m;
if (reliability != null)
{
if (reliability.RecommendedAction == "BOOST_SCORE" || (reliability.ProfitFactor >= 1.60m && reliability.SampleTradeCount >= 5))
{
matrixBonus = 15.0m;
await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorer] Simulation matrix bonus (+15 pts) applied for {Isin} ({Strategy}): PF={PF:F2}, WR={WR:F1}%",
setup.Isin, setup.StrategyKey, reliability.ProfitFactor, reliability.WinRatePercent);
}
else if (reliability.RecommendedAction == "VETO_DISABLE" || (!reliability.IsStrategyApprovedForAsset && reliability.SampleTradeCount >= 5))
{
passedVeto = false;
mVeto = 0.20m; // Heavy suppression penalty
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorer] Simulation matrix VETO applied for {Isin} ({Strategy}): PF={PF:F2} < 1.00. Suppressing score.",
setup.Isin, setup.StrategyKey, reliability.ProfitFactor);
}
}
// 6. Calculate Weighted Composite Opportunity Score (COS)
decimal rawScore = (wTech * sTech) + (wSent * sSent) + (wFund * sFund) + matrixBonus;
decimal finalCos = Math.Clamp(rawScore * mEarnings * mDividend * mVeto, 0m, 100m);
await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel,
"[CompositeScorer] ISIN {Isin} evaluated: COS={Cos:F1} (Tech={Tech:F1}, Sent={Sent:F1}, Fund={Fund:F1}, Bonus={Bonus}, Veto={Veto}, Lockout={Lockout}, DividendGate={DividendGate})",
setup.Isin, finalCos, sTech, sSent, sFund, matrixBonus, passedVeto, passedLockout, passedDividendGate);
return new ScoringResult(
CompositeScore: Math.Round(finalCos, 2),
TechnicalScore: Math.Round(sTech, 2),
SentimentScore: Math.Round(sSent, 2),
FundamentalScore: Math.Round(sFund, 2),
PassedEarningsLockout: passedLockout,
DaysToNextEarnings: daysToEarnings,
ReliabilityBonus: matrixBonus,
PassedSimulationVeto: passedVeto,
PassedDividendGate: passedDividendGate,
DaysToNextExDividend: daysToExDividend
);
}
}
@@ -0,0 +1,31 @@
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticEngine.Services.Scoring;
public record ScoringResult(
decimal CompositeScore,
decimal TechnicalScore,
decimal SentimentScore,
decimal FundamentalScore,
bool PassedEarningsLockout,
int? DaysToNextEarnings,
decimal ReliabilityBonus = 0m,
bool PassedSimulationVeto = true,
bool PassedDividendGate = true,
int? DaysToNextExDividend = null
);
public interface ICompositeOpportunityScorer
{
Task<ScoringResult> CalculateCompositeScoreAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticEngine.Database;
using FinlyticEngine.Database.Entities;
using FinlyticEngine.Services.Mqtt;
using FinlyticEngine.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticEngine.Services.Trading;
public record GetCandlesRpcRequest(
string Isin = "",
string Timeframe = "15m"
);
public class ActiveTradeMonitoringBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IEngineRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<ActiveTradeMonitoringBackgroundService> _logger;
public ActiveTradeMonitoringBackgroundService(
IServiceScopeFactory scopeFactory,
IEngineRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<ActiveTradeMonitoringBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[ActiveTradeMonitor] Starting active trade lifecycle monitoring service.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.MonitoringIntervalSeconds, stoppingToken);
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
var activeTrades = await db.Trades
.Include(t => t.Fills)
.Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.BreakEvenTriggered || t.Status == TradeStatus.Tp1Hit || t.Status == TradeStatus.Tp2Hit)
.ToListAsync(stoppingToken);
if (activeTrades.Count > 0)
{
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[ActiveTradeMonitor] Monitoring {Count} active trades against live price feeds.", activeTrades.Count);
foreach (var trade in activeTrades)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
// 1. Fetch latest candle for current price
var candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, GetCandlesRpcRequest>(
"ta_GetCandles",
new GetCandlesRpcRequest(trade.UnderlyingIsin, "1m"),
TimeSpan.FromSeconds(3)
);
if (candles == null || candles.Count == 0)
{
continue;
}
var latestCandle = candles.Last();
decimal currentPrice = latestCandle.Close;
trade.CurrentPrice = currentPrice;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
// 2. Check Stop-Loss Violation
bool isStoppedOut = false;
if (trade.Direction == SignalDirection.Buy && currentPrice <= trade.CurrentStopLoss)
{
isStoppedOut = true;
}
else if (trade.Direction == SignalDirection.Sell && currentPrice >= trade.CurrentStopLoss)
{
isStoppedOut = true;
}
if (isStoppedOut)
{
trade.Status = TradeStatus.StoppedOut;
trade.ClosedAtUtc = DateTime.UtcNow;
if (trade.Direction == SignalDirection.Buy)
{
trade.RealizedPnlEur = ((currentPrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur;
}
else
{
trade.RealizedPnlEur = ((trade.AverageBuyIn - currentPrice) * trade.TotalQuantity) - trade.TotalFeesEur;
}
await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel,
"[ActiveTradeMonitor] Trade {TradeId} for {Isin} STOPPED OUT at {Price:F2} € (SL: {SL:F2} €, PnL: {PnL:F2} €)",
trade.Id, trade.UnderlyingIsin, currentPrice, trade.CurrentStopLoss, trade.RealizedPnlEur);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
continue;
}
// 3. Check Break-Even Trigger (Free-Roll when TP1 is hit)
bool isTp1Reached = false;
if (trade.Direction == SignalDirection.Buy && currentPrice >= trade.TakeProfit1)
{
isTp1Reached = true;
}
else if (trade.Direction == SignalDirection.Sell && currentPrice <= trade.TakeProfit1)
{
isTp1Reached = true;
}
if (isTp1Reached && trade.Status == TradeStatus.Active)
{
decimal oldSl = trade.CurrentStopLoss;
trade.CurrentStopLoss = trade.AverageBuyIn; // Move SL to Break-Even (Free-Roll)
trade.Status = TradeStatus.BreakEvenTriggered;
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[ActiveTradeMonitor] Trade {TradeId} for {Isin} hit TP1 ({TP1:F2} €). Moving SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)",
trade.Id, trade.UnderlyingIsin, trade.TakeProfit1, oldSl, trade.AverageBuyIn);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
}
// 4. Check Trailing Stop logic
if (trade.ExitPlan?.TrailingStopRule != null && trade.Status == TradeStatus.BreakEvenTriggered)
{
var rule = trade.ExitPlan.TrailingStopRule;
if (trade.Direction == SignalDirection.Buy && currentPrice > rule.ActivationPrice)
{
decimal trailingSl = currentPrice * 0.97m; // 3% trail
if (trailingSl > trade.CurrentStopLoss)
{
trade.CurrentStopLoss = Math.Round(trailingSl, 2);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[ActiveTradeMonitor] Trailing SL for trade {TradeId} moved up to {NewSl:F2} €",
trade.Id, trade.CurrentStopLoss);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
}
}
}
await db.SaveChangesAsync(stoppingToken);
}
catch (Exception ex)
{
await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel, ex,
"[ActiveTradeMonitor] Error evaluating active trade {TradeId}", trade.Id);
}
}
}
}
await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(EngineSettingKeys.TradeLifecycleChannel, ex,
"[ActiveTradeMonitor] Unexpected error in monitoring loop. Waiting 15s.");
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
}
}
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[ActiveTradeMonitor] Active trade lifecycle monitoring service stopped.");
}
private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e)
{
decimal unrealizedPnlEur = 0m;
decimal unrealizedPnlPercent = 0m;
if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0)
{
if (e.Direction == SignalDirection.Buy)
{
unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity;
unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m;
}
else
{
unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity;
unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m;
}
}
return new ActiveTradeDto(
TradeId: e.Id,
ProposalId: e.ProposalId,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin,
DerivativeWkn: e.DerivativeWkn,
ExecutionMode: e.ExecutionMode,
InstrumentType: e.InstrumentType,
Direction: e.Direction,
Status: e.Status,
AverageBuyIn: e.AverageBuyIn,
TotalQuantity: e.TotalQuantity,
InitialStopLoss: e.InitialStopLoss,
CurrentStopLoss: e.CurrentStopLoss,
CurrentPrice: e.CurrentPrice,
UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2),
UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2),
RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2),
ExitPlan: e.ExitPlan,
Fills: e.Fills.Select(f => new TradeFillDto(
FillId: f.Id,
ExecutedAtUtc: f.ExecutedAtUtc,
Price: f.Price,
Quantity: f.Quantity,
Fee: f.Fee,
Note: f.Note
)).ToList(),
OpenedAtUtc: e.OpenedAtUtc,
ClosedAtUtc: e.ClosedAtUtc
);
}
}
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticEngine.Services.Trading;
/// <summary>
/// Serves the admin-only evaluation-history query (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) over
/// <c>EngineEvaluationSnapshotEntity</c>. Deliberately kept as its own focused interface rather than folded
/// into <see cref="ITradeLifecycleService"/>: this is a read-only reporting/audit query with none of
/// <see cref="ITradeLifecycleService"/>'s dependencies (AI gate, derivative resolver, composite scorer) and a
/// completely different caller (the admin Web UI tab, not the trading pipeline) - mirroring how
/// <see cref="Scoring.ICompositeOpportunityScorer"/>, <see cref="Ai.IAiReasoningGateService"/> and
/// <see cref="Derivatives.IKnockOutDerivativeResolver"/> are already separate, single-purpose services instead
/// of being methods on <see cref="ITradeLifecycleService"/>.
/// </summary>
public interface IEvaluationHistoryService
{
/// <summary>
/// Returns a filtered, paginated page of evaluation-history rows plus a pre-aggregated summary over the
/// same (unpaginated) filtered set. See <see cref="GetEvaluationHistoryRequest"/> and
/// <see cref="EvaluationHistorySummaryDto"/> for the exact filter/aggregation semantics.
/// </summary>
Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default);
}
public class EvaluationHistoryService : IEvaluationHistoryService
{
/// <summary>
/// Hard cap on <see cref="GetEvaluationHistoryRequest.PageSize"/> so a caller cannot force FinlyticEngine
/// to materialize/transmit an unbounded result set in a single response (Rules.md-style defensive default,
/// requested explicitly by the task brief).
/// </summary>
private const int MaxPageSize = 200;
private const int DefaultPageSize = 50;
private readonly IServiceScopeFactory _scopeFactory;
public EvaluationHistoryService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
/// <inheritdoc />
public async Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
int page = Math.Max(1, request.Page);
int pageSize = Math.Clamp(request.PageSize <= 0 ? DefaultPageSize : request.PageSize, 1, MaxPageSize);
var query = db.Snapshots.AsNoTracking().AsQueryable();
if (request.FromUtc.HasValue)
{
query = query.Where(s => s.EvaluatedAtUtc >= request.FromUtc.Value);
}
if (request.ToUtc.HasValue)
{
query = query.Where(s => s.EvaluatedAtUtc <= request.ToUtc.Value);
}
if (request.OutcomeFilter.HasValue)
{
query = query.Where(s => s.OutcomeReason == request.OutcomeFilter.Value);
}
if (request.TriggerSourceFilter.HasValue)
{
query = query.Where(s => s.TriggerSource == request.TriggerSourceFilter.Value);
}
if (!string.IsNullOrWhiteSpace(request.IsinOrSymbolSearch))
{
var term = request.IsinOrSymbolSearch.Trim();
query = query.Where(s => s.Isin.Contains(term) || s.Symbol.Contains(term));
}
int totalCount = await query.CountAsync(cancellationToken);
var pageEntities = await query
.OrderByDescending(s => s.EvaluatedAtUtc)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var entries = pageEntities.Select(MapSnapshotToDto).ToList();
// Summary is computed over the SAME filtered (but unpaginated) set as the page above - see
// EvaluationHistorySummaryDto's doc comment for why, and why LastProposalCreatedAtUtc is the one
// deliberate exception that ignores the From/To filters.
var outcomeCounts = await query
.GroupBy(s => s.OutcomeReason)
.Select(g => new OutcomeReasonCountDto(g.Key, g.Count()))
.ToListAsync(cancellationToken);
decimal averageScore = totalCount > 0
? Math.Round(await query.AverageAsync(s => s.CompositeOpportunityScore, cancellationToken), 2)
: 0m;
int proposalsCreated = outcomeCounts.FirstOrDefault(c => c.OutcomeReason == OutcomeReason.Approved)?.Count ?? 0;
DateTime? lastProposalCreatedAtUtc = await db.TradeProposals.AsNoTracking()
.OrderByDescending(p => p.CreatedAtUtc)
.Select(p => (DateTime?)p.CreatedAtUtc)
.FirstOrDefaultAsync(cancellationToken);
var summary = new EvaluationHistorySummaryDto(
TotalEvaluations: totalCount,
CountsByOutcome: outcomeCounts,
AverageCompositeScore: averageScore,
ProposalsCreated: proposalsCreated,
LastProposalCreatedAtUtc: lastProposalCreatedAtUtc
);
return new GetEvaluationHistoryResponse(totalCount, entries, summary);
}
/// <summary>
/// Maps a persisted <see cref="Database.Entities.EngineEvaluationSnapshotEntity"/> row 1:1 onto its wire DTO.
/// </summary>
private static EvaluationHistoryEntryDto MapSnapshotToDto(Database.Entities.EngineEvaluationSnapshotEntity e)
{
return new EvaluationHistoryEntryDto(
Id: e.Id,
Isin: e.Isin,
Symbol: e.Symbol,
TechnicalScore: e.TechnicalScore,
SentimentScore: e.SentimentScore,
FundamentalScore: e.FundamentalScore,
CompositeOpportunityScore: e.CompositeOpportunityScore,
ReliabilityBonus: e.ReliabilityBonus,
PassedEarningsLockout: e.PassedEarningsLockout,
DaysToNextEarnings: e.DaysToNextEarnings,
PassedDividendGate: e.PassedDividendGate,
DaysToNextExDividend: e.DaysToNextExDividend,
UniverseSource: e.UniverseSource,
UniverseEnteredAtUtc: e.UniverseEnteredAtUtc,
PassedSimulationVeto: e.PassedSimulationVeto,
PassedAiValidation: e.PassedAiValidation,
AiThesisSummary: e.AiThesisSummary,
OutcomeReason: e.OutcomeReason,
TriggerSource: e.TriggerSource,
TriggeredByUserId: e.TriggeredByUserId,
ProposalId: e.ProposalId,
EvaluatedAtUtc: e.EvaluatedAtUtc
);
}
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Trading;
namespace FinlyticEngine.Services.Trading;
/// <summary>
/// Coordinates the full trade proposal/trade lifecycle for FinlyticEngine: on-demand evaluation, proposal
/// acceptance/rejection, and management of the resulting active trades (fills, stop-loss updates, closes).
/// </summary>
public interface ITradeLifecycleService
{
/// <summary>
/// Returns trade proposals, optionally restricted to still-active, non-expired ones.
/// </summary>
Task<List<TradeProposalDto>> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the active trades owned by <paramref name="userId"/>, optionally filtered by
/// <see cref="ExecutionMode"/>. The filter is applied in the database, so another user's trades are never
/// materialised and a caller cannot widen the result set by omitting a parameter.
/// </summary>
Task<List<ActiveTradeDto>> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default);
/// <summary>
/// Runs the full multi-factor evaluation pipeline (technicals, sentiment, fundamentals, simulation feedback,
/// AI reasoning gate) for a single ISIN and persists a new <see cref="TradeProposalDto"/> if the opportunity
/// is approved. Unlike the old <c>TradeProposalDto?</c> contract, this never returns <see langword="null"/>:
/// a rejection (score too low, or the AI gate declined) is reported as an
/// <see cref="AssetEvaluationResultDto"/> with <c>Proposal == null</c> but with the real, already-computed
/// scores and AI reasoning filled in, so a caller always learns *why*, not just *that* no proposal was made
/// (Rules.md §4). When not even a technical setup could be found for the ISIN, the score fields are <c>0</c>
/// and <see cref="AssetEvaluationResultDto.AiThesisSummary"/> carries a "<c>[Regelbasiert]</c>"-prefixed
/// explanation rather than a fabricated AI verdict.
/// <para>
/// Every call - including the early "no technical setup"/"blank ISIN" returns - now persists exactly one
/// <c>EngineEvaluationSnapshotEntity</c> row tagged with <paramref name="triggerSource"/> (and
/// <paramref name="triggeredByUserId"/> when <paramref name="triggerSource"/> is
/// <see cref="TriggerSource.Manual"/>), so the admin evaluation-history tab
/// (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) can account for every asset this pipeline ever
/// looked at, not only the ones that made it all the way to scoring.
/// </para>
/// <para>
/// An approval that would otherwise create a second <see cref="TradeProposalDto"/> for an ISIN that
/// already has an active, non-expired proposal is deduplicated: no new proposal row is created and no
/// <c>finlytic/engine/proposals/created</c> event is re-broadcast, the persisted snapshot's
/// <c>OutcomeReason</c> is <see cref="OutcomeReason.DuplicateActiveProposal"/> instead of
/// <see cref="OutcomeReason.Approved"/>, and the returned <see cref="AssetEvaluationResultDto.Proposal"/> is
/// the pre-existing proposal (never <see langword="null"/>) so a caller still learns about the open
/// opportunity. This exists because the autonomous scanner re-evaluates the same top-picks every cycle and
/// would otherwise create a near-identical proposal (and broadcast) for as long as one asset stays above
/// the approval threshold.
/// </para>
/// </summary>
/// <param name="isin">The underlying ISIN to evaluate.</param>
/// <param name="ticker">Optional ticker hint passed through to the technical/fundamentals lookups.</param>
/// <param name="forceAiEvaluation">
/// When <see langword="true"/>, the AI reasoning gate is consulted even if the composite score is below
/// <c>Engine.MinCompositeScore</c> (used by the manual "Analyze now" Web UI flow).
/// </param>
/// <param name="triggerSource">
/// Whether this call originates from the autonomous <c>OpportunityPollerBackgroundService</c> scan loop
/// (<see cref="TriggerSource.Automatic"/>, the default) or an on-demand human request
/// (<see cref="TriggerSource.Manual"/>).
/// </param>
/// <param name="triggeredByUserId">
/// The identity of the human caller when <paramref name="triggerSource"/> is <see cref="TriggerSource.Manual"/>.
/// Must be <see langword="null"/> for <see cref="TriggerSource.Automatic"/> calls - the autonomous scanner
/// never carries a user identity, and this is enforced defensively regardless of what is passed in.
/// </param>
/// <param name="cancellationToken">Propagated to every downstream MQTT/DB call.</param>
Task<AssetEvaluationResultDto> EvaluateAssetAsync(
string isin,
string? ticker = null,
bool forceAiEvaluation = false,
TriggerSource triggerSource = TriggerSource.Automatic,
Guid? triggeredByUserId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Records an additional executed fill against an existing active trade and recalculates its average
/// buy-in, total quantity, fees, and dynamic take-profit levels.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>. A trade owned
/// by a different user is reported the same way as a missing one, so ownership is never disclosed.
/// </exception>
Task<ActiveTradeDto> AddTradeFillAsync(Guid userId, Guid tradeId, decimal executedPrice, decimal quantity, decimal fee = 0m, string? note = null, CancellationToken cancellationToken = default);
/// <summary>
/// Manually or algorithmically adjusts the stop-loss of an active trade owned by <paramref name="userId"/>.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>.
/// </exception>
Task<ActiveTradeDto> UpdateStopLossAsync(Guid userId, Guid tradeId, decimal newStopLoss, string reason, CancellationToken cancellationToken = default);
/// <summary>
/// Closes an active trade owned by <paramref name="userId"/> at the given price and computes its realized P&amp;L.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>.
/// </exception>
Task<ActiveTradeDto> CloseTradeAsync(Guid userId, Guid tradeId, decimal closePrice, string reason, CancellationToken cancellationToken = default);
/// <summary>
/// Creates an actively tracked <c>EngineTradeEntity</c> owned by <paramref name="userId"/> from an open
/// proposal. The source proposal is deliberately left active: a proposal is a system-wide opportunity that
/// several users may accept independently, each receiving their own trade. Proposals are not consumed by
/// acceptance — they disappear on their own once <c>ExpiresAtUtc</c> passes.
/// </summary>
/// <returns><see langword="null"/> if no active, non-expired proposal with <paramref name="proposalId"/> exists.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="userId"/> already holds a trade created from this proposal.
/// </exception>
Task<ActiveTradeDto?> CreateTradeFromProposalAsync(Guid userId, Guid proposalId, ExecutionMode mode, decimal? initialFillPrice = null, decimal? initialQuantity = null, CancellationToken cancellationToken = default);
/// <summary>
/// Accepts a proposal on behalf of a single user via the <c>engine_AcceptProposal</c> MQTT RPC channel.
/// Thin wrapper around <see cref="CreateTradeFromProposalAsync"/> — see there for the ownership and
/// non-consumption semantics. Declining a proposal deliberately has no counterpart here: it has no
/// server-side effect and is handled entirely in the client.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The proposal does not exist, has expired, or this user already accepted it.
/// </exception>
Task<ActiveTradeDto> AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Opens an actively tracked trade owned by <c>request.UserId</c> with no backing proposal (manual entry,
/// e.g. from the Web UI). Unlike <see cref="CreateTradeFromProposalAsync"/>, the resulting
/// <c>EngineTradeEntity.ProposalId</c> is <see cref="Guid.Empty"/> since there is no proposal to link to.
/// </summary>
/// <exception cref="ArgumentException">
/// <c>UnderlyingIsin</c>/<c>Symbol</c> is blank, or <c>EntryPrice</c>/<c>Quantity</c> is not positive.
/// </exception>
Task<ActiveTradeDto> CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticEngine.Database;
using FinlyticEngine.Database.Entities;
using FinlyticEngine.Services.Mqtt;
using FinlyticEngine.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticEngine.Services.Trading;
public record GetSetupsRpcRequest(
bool TopPicksOnly = true,
int Limit = 30,
decimal? MinScore = 70.0m
);
public class OpportunityPollerBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IEngineRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<OpportunityPollerBackgroundService> _logger;
public OpportunityPollerBackgroundService(
IServiceScopeFactory scopeFactory,
IEngineRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<OpportunityPollerBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[OpportunityPoller] Starting background opportunity scanner.");
// Initial grace delay for MQTT network stabilization
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.PollingIntervalSeconds, stoppingToken);
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[OpportunityPoller] Querying active top-picks from FinlyticTechnicals...");
var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerMinScore, stoppingToken);
var topPicksOnly = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerTopPicksOnly, stoppingToken);
var limit = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerLimit, stoppingToken);
var req = new GetSetupsRpcRequest(TopPicksOnly: topPicksOnly, Limit: limit, MinScore: minScore);
var topPicks = await _rpcClient.SendRpcRequestAsync<List<StrategyResultDto>, GetSetupsRpcRequest>(
"ta_GetSetups",
req,
TimeSpan.FromSeconds(5)
);
// Task 3 (scan-universe visibility): only persist a cycle row once FinlyticTechnicals actually
// answered - topPicks == null means the RPC itself timed out/failed (already logged/handled
// below), which is a transport failure, not a legitimate "zero candidates this cycle" scan
// outcome, so it deliberately does not get a row here.
if (topPicks != null)
{
await PersistScanCycleAsync(req, topPicks, stoppingToken);
}
if (topPicks != null && topPicks.Count > 0)
{
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[OpportunityPoller] Received {Count} top-picks from FinlyticTechnicals. Evaluating opportunities...",
topPicks.Count);
using var scope = _scopeFactory.CreateScope();
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
foreach (var pick in topPicks)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
// Result is intentionally not surfaced anywhere beyond this log line: the poller is
// an autonomous background scanner with no human waiting on a per-asset rejection
// reason, unlike the on-demand RPC callers (AnalyzeController/EngineController).
var evaluation = await lifecycleService.EvaluateAssetAsync(
pick.Isin, pick.Symbol, forceAiEvaluation: false,
triggerSource: TriggerSource.Automatic, triggeredByUserId: null,
cancellationToken: stoppingToken);
if (evaluation.Proposal == null)
{
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[OpportunityPoller] {Isin} evaluated, no proposal (COS={Cos:F1}, AiApproved={AiApproved}): {Reason}",
pick.Isin, evaluation.CompositeScore, evaluation.AiApproved, evaluation.AiThesisSummary);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel, ex,
"[OpportunityPoller] Failed to evaluate top-pick ISIN {Isin}", pick.Isin);
}
// Gentle throttle between evaluations
await Task.Delay(250, stoppingToken);
}
}
else
{
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[OpportunityPoller] No active top-picks available at this time.");
}
await Task.Delay(TimeSpan.FromSeconds(Math.Max(10, intervalSec)), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(EngineSettingKeys.EngineChannel, ex,
"[OpportunityPoller] Unexpected error in scanner cycle. Retrying in 30 seconds.");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[OpportunityPoller] Background opportunity scanner stopped.");
}
/// <summary>
/// Persists a minimal <see cref="EngineScanCycleEntity"/> row recording exactly which ISINs
/// FinlyticTechnicals returned as technical top-picks for this poll cycle - i.e. the engine-side candidate
/// set that <c>ITradeLifecycleService.EvaluateAssetAsync</c> is about to be called for (Task 3:
/// scan-universe visibility).
/// This is deliberately NOT the full universe FinlyticTechnicals monitors before that top-picks filter is
/// applied (favorites/discovery/sentiment-spike ISINs live entirely inside
/// <c>FinlyticTechnicals.Services.TechnicalUniverseManager</c>, out of scope for this table) - see the
/// implementing task's report for why that broader pre-filter visibility was not added here.
/// </summary>
private async Task PersistScanCycleAsync(GetSetupsRpcRequest request, List<StrategyResultDto> topPicks, CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
db.ScanCycles.Add(new EngineScanCycleEntity
{
Id = Guid.NewGuid(),
CycleStartedAtUtc = DateTime.UtcNow,
RequestedLimit = request.Limit,
RequestedMinScore = request.MinScore,
CandidatesReturnedCount = topPicks.Count,
CandidateIsins = topPicks.Select(p => p.Isin).ToList()
});
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,921 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticEngine.Database;
using FinlyticEngine.Database.Entities;
using FinlyticEngine.Services.Ai;
using FinlyticEngine.Services.Derivatives;
using FinlyticEngine.Services.Mqtt;
using FinlyticEngine.Services.Scoring;
using FinlyticEngine.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticEngine.Services.Trading;
public class TradeLifecycleService : ITradeLifecycleService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ICompositeOpportunityScorer _scorer;
private readonly IAiReasoningGateService _aiGate;
private readonly IKnockOutDerivativeResolver _derivativeResolver;
private readonly IEngineRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<TradeLifecycleService> _logger;
public TradeLifecycleService(
IServiceScopeFactory scopeFactory,
ICompositeOpportunityScorer scorer,
IAiReasoningGateService aiGate,
IKnockOutDerivativeResolver derivativeResolver,
IEngineRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<TradeLifecycleService> logger)
{
_scopeFactory = scopeFactory;
_scorer = scorer;
_aiGate = aiGate;
_derivativeResolver = derivativeResolver;
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
public async Task<List<TradeProposalDto>> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var query = db.TradeProposals.AsNoTracking();
if (onlyActive)
{
var now = DateTime.UtcNow;
query = query.Where(p => p.IsActive && p.ExpiresAtUtc > now);
}
var list = await query
.OrderByDescending(p => p.CompositeScore)
.Take(limit)
.ToListAsync(cancellationToken);
return list.Select(MapProposalEntityToDto).ToList();
}
public async Task<List<ActiveTradeDto>> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
// Tenant boundary: applied before any other predicate so another user's rows are never materialised.
var query = db.Trades
.Include(t => t.Fills)
.AsNoTracking()
.Where(t => t.UserId == userId)
.Where(t => t.Status != TradeStatus.Closed && t.Status != TradeStatus.StoppedOut && t.Status != TradeStatus.Invalidated && t.Status != TradeStatus.Expired);
if (mode.HasValue)
{
query = query.Where(t => t.ExecutionMode == mode.Value);
}
var list = await query
.OrderByDescending(t => t.OpenedAtUtc)
.ToListAsync(cancellationToken);
return list.Select(MapTradeEntityToDto).ToList();
}
/// <summary>
/// Builds an honest "nothing to evaluate" <see cref="AssetEvaluationResultDto"/> for the cases where the
/// pipeline could not even produce a real score (blank ISIN, or no technical setups found). All score
/// fields are <c>0</c>/<c>null</c> rather than fabricated, and <paramref name="reason"/> is prefixed with
/// the same "<c>[Regelbasiert]</c>" marker <see cref="AiValidationResultDto"/> uses for its
/// <see cref="ValidationSource.RuleBased"/> fallback, so a caller/UI never mistakes this for a real AI
/// verdict (Rules.md §4).
/// </summary>
private static AssetEvaluationResultDto BuildNoEvaluationResult(string reason)
{
return new AssetEvaluationResultDto(
Proposal: null,
CompositeScore: 0m,
TechnicalScore: 0m,
SentimentScore: 0m,
FundamentalScore: 0m,
PassedEarningsLockout: true,
DaysToNextEarnings: null,
PassedDividendGate: true,
DaysToNextExDividend: null,
AiApproved: false,
AiThesisSummary: $"[Regelbasiert] {reason}",
AiIdentifiedRisks: new List<string>()
);
}
/// <summary>
/// Persists an <see cref="EngineEvaluationSnapshotEntity"/> row for the two early-return cases in
/// <see cref="EvaluateAssetAsync"/> (blank ISIN, no technical setups) and returns the same
/// <see cref="BuildNoEvaluationResult"/> DTO the caller would have received before these rows existed.
/// All score fields are recorded as <c>0</c>/default - identical to <see cref="BuildNoEvaluationResult"/>'s
/// own honesty guarantee - since the pipeline never reached scoring for these two cases (Rules.md §4).
/// </summary>
/// <param name="isinForRecord">The (possibly blank) ISIN to record on the snapshot row.</param>
/// <param name="reason">Human-readable reason, reused verbatim from <see cref="BuildNoEvaluationResult"/>.</param>
/// <param name="triggerSource">Whether this evaluation was automatic or manual.</param>
/// <param name="triggeredByUserId">The manual caller's identity, or <see langword="null"/> for automatic runs.</param>
/// <param name="cancellationToken">Propagated to the snapshot insert.</param>
private async Task<AssetEvaluationResultDto> PersistNoEvaluationSnapshotAsync(
string isinForRecord,
string reason,
TriggerSource triggerSource,
Guid? triggeredByUserId,
CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
db.Snapshots.Add(new EngineEvaluationSnapshotEntity
{
Id = Guid.NewGuid(),
Isin = isinForRecord,
Symbol = string.Empty,
TechnicalScore = 0m,
SentimentScore = 0m,
FundamentalScore = 0m,
CompositeOpportunityScore = 0m,
ReliabilityBonus = 0m,
PassedEarningsLockout = true,
DaysToNextEarnings = null,
PassedDividendGate = true,
DaysToNextExDividend = null,
UniverseSource = null,
UniverseEnteredAtUtc = null,
PassedSimulationVeto = true,
PassedAiValidation = false,
AiThesisSummary = $"[Regelbasiert] {reason}",
TriggerSource = triggerSource,
TriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null,
OutcomeReason = OutcomeReason.NoTechnicalSetups,
ProposalId = null,
EvaluatedAtUtc = DateTime.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
return BuildNoEvaluationResult(reason);
}
/// <summary>
/// Derives which <see cref="OutcomeReason"/> best explains a completed evaluation (i.e. one that reached
/// scoring - the earlier "no technical setup" case always short-circuits to
/// <see cref="OutcomeReason.NoTechnicalSetups"/> and never reaches this method). Note that a result of
/// <see cref="OutcomeReason.Approved"/> from this method is provisional: <see cref="EvaluateAssetAsync"/>
/// downgrades it to <see cref="OutcomeReason.DuplicateActiveProposal"/> immediately afterwards if an
/// active, non-expired proposal already exists for the same ISIN, since no second proposal row is created
/// in that case.
/// <para>
/// Priority order when more than one gate failed simultaneously (first match wins):
/// </para>
/// <list type="number">
/// <item><description>
/// <see cref="OutcomeReason.Approved"/> - the AI reasoning gate approved the opportunity.
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.EarningsLockout"/> - <paramref name="passedEarningsLockout"/> is
/// <see langword="false"/>. Checked before the score threshold even though the score gate is evaluated
/// later in the pipeline, because the lockout's suppression multiplier
/// (<c>CompositeOpportunityScorer</c>'s <c>mEarnings = 0.15</c>) is usually *why* the score ended up below
/// threshold in the first place - reporting only "score too low" would hide the actual, actionable cause.
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.SimulationVeto"/> - <paramref name="passedSimulationVeto"/> is
/// <see langword="false"/>, for the same reason as the lockout case above (its own suppression multiplier,
/// <c>mVeto = 0.20</c>, likewise drives the score down).
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.DividendGate"/> - <paramref name="passedDividendGate"/> is
/// <see langword="false"/>. Checked last among the three suppression gates since it is the mildest
/// (<c>mDividend = 0.5</c> vs. earnings' 0.15 and the simulation veto's 0.20) - a predictable, mechanical
/// ex-dividend price adjustment rather than a fundamental surprise or a failed backtest.
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.BelowScoreThreshold"/> - none of the three hard gates above fired, but
/// <paramref name="scoreGateOpened"/> is <see langword="false"/>, meaning the composite score never reached
/// <c>Engine.MinCompositeScore</c> and the evaluation was not forced, so the AI reasoning gate was never
/// even consulted (a synthetic rule-based rejection was recorded instead).
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.AiRejected"/> - everything upstream cleared (<paramref name="scoreGateOpened"/>
/// is <see langword="true"/>, both hard gates passed) but the AI reasoning gate itself - whether a real AI
/// call or one of its own internal rule-based fallbacks (gate disabled, webhook unreachable) - still
/// declined. This is deliberately the last, most specific fallback: everything else has already been
/// ruled out by the time this is reached.
/// </description></item>
/// </list>
/// </summary>
/// <param name="aiApproved"><c>AiValidationResultDto.IsApproved</c> from the (possibly rule-based) AI gate result.</param>
/// <param name="passedEarningsLockout"><c>ScoringResult.PassedEarningsLockout</c>.</param>
/// <param name="passedSimulationVeto"><c>ScoringResult.PassedSimulationVeto</c>.</param>
/// <param name="scoreGateOpened">
/// Whether the composite score cleared <c>Engine.MinCompositeScore</c> or the evaluation was forced - i.e.
/// the exact condition under which the AI reasoning gate was actually consulted rather than synthetically
/// rejected.
/// </param>
/// <returns>The single best-matching <see cref="OutcomeReason"/> for this evaluation.</returns>
private static OutcomeReason DetermineOutcomeReason(
bool aiApproved,
bool passedEarningsLockout,
bool passedSimulationVeto,
bool passedDividendGate,
bool scoreGateOpened)
{
if (aiApproved) return OutcomeReason.Approved;
if (!passedEarningsLockout) return OutcomeReason.EarningsLockout;
if (!passedSimulationVeto) return OutcomeReason.SimulationVeto;
if (!passedDividendGate) return OutcomeReason.DividendGate;
if (!scoreGateOpened) return OutcomeReason.BelowScoreThreshold;
return OutcomeReason.AiRejected;
}
/// <inheritdoc />
public async Task<AssetEvaluationResultDto> EvaluateAssetAsync(
string isin,
string? ticker = null,
bool forceAiEvaluation = false,
TriggerSource triggerSource = TriggerSource.Automatic,
Guid? triggeredByUserId = null,
CancellationToken cancellationToken = default)
{
// Automatic runs never carry a user identity, enforced here regardless of what a caller passed in, so
// a programming mistake upstream can never leak a stale/wrong UserId onto an automatic snapshot row.
var effectiveTriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null;
if (string.IsNullOrWhiteSpace(isin))
{
return await PersistNoEvaluationSnapshotAsync(
string.Empty, "Keine gültige ISIN angegeben.", triggerSource, effectiveTriggeredByUserId, cancellationToken);
}
var cleanIsin = isin.Trim().ToUpperInvariant();
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[TradeLifecycle] Starting on-demand evaluation for ISIN {Isin} (Ticker: {Ticker})", cleanIsin, ticker ?? "N/A");
// 1. Fetch Technical Analysis Setups from FinlyticTechnicals
var taSetups = await _rpcClient.SendRpcRequestAsync<List<StrategyResultDto>, IsinRequest>(
MqttTopics.Channels.TaGetSetupsForIsin,
new IsinRequest(cleanIsin, ticker, ForceRefresh: false),
TimeSpan.FromSeconds(5)
);
if (taSetups == null || taSetups.Count == 0)
{
await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel,
"[TradeLifecycle] No technical setups returned for {Isin}", cleanIsin);
return await PersistNoEvaluationSnapshotAsync(
cleanIsin, $"Keine technischen Setups für {cleanIsin} verfügbar.", triggerSource, effectiveTriggeredByUserId, cancellationToken);
}
// Pick top technical setup
var bestSetup = taSetups.OrderByDescending(s => s.QualityScore).First();
// 2. Parallel Fetch: Sentiment, Fundamentals & Simulation Matrix
var sentTask = _rpcClient.SendRpcRequestAsync<IsinSentimentSummaryDto, GetSentimentByIsinRequest>(
MqttTopics.Channels.SentimentGetIsin,
new GetSentimentByIsinRequest(cleanIsin),
TimeSpan.FromSeconds(3)
);
var fundTask = _rpcClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
MqttTopics.Channels.FundamentalsGet,
new IsinRequest(cleanIsin, ticker, ForceRefresh: false),
TimeSpan.FromSeconds(4)
);
var matrixTask = _rpcClient.SendRpcRequestAsync<FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto, FinlyticCore.Dtos.Simulation.GetReliabilityRequest>(
MqttTopics.Channels.SimGetReliability,
new FinlyticCore.Dtos.Simulation.GetReliabilityRequest(cleanIsin, bestSetup.StrategyKey),
TimeSpan.FromSeconds(3)
);
await Task.WhenAll(sentTask, fundTask, matrixTask);
var sentiment = await sentTask;
var fundamentals = await fundTask;
var reliability = await matrixTask;
// 3. Multi-Faktor Composite Opportunity Scoring (COS) with Simulation Feedback
var scoringResult = await _scorer.CalculateCompositeScoreAsync(bestSetup, sentiment, fundamentals, reliability, cancellationToken);
var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken);
// 4. AI Reasoning Gate
// Captured explicitly (rather than re-evaluating the same expression later) because
// DetermineOutcomeReason needs to know precisely whether the AI gate was ever consulted, to tell
// apart OutcomeReason.BelowScoreThreshold (never consulted) from OutcomeReason.AiRejected (consulted,
// declined) below.
bool scoreGateOpened = scoringResult.CompositeScore >= minScore || forceAiEvaluation;
AiValidationResultDto aiValidation;
if (scoreGateOpened)
{
aiValidation = await _aiGate.ValidateOpportunityAsync(bestSetup, sentiment, fundamentals, scoringResult, reliability, cancellationToken);
}
else
{
aiValidation = new AiValidationResultDto(
IsApproved: false,
Confidence: null,
Source: ValidationSource.RuleBased,
ThesisSummary: $"[Regelbasiert] Score {scoringResult.CompositeScore:F1} liegt unter Mindestwert ({minScore:F1}).",
InvalidationReason: "Unzureichende Multi-Faktor Confluence.",
KeyCatalysts: new List<string>(),
IdentifiedRisks: new List<string> { "Niedriger Gesamtscore" }
);
}
// 5. Knock-Out Derivative Selection
DerivativeSelectionDto? selectedDerivative = null;
if (aiValidation.IsApproved || forceAiEvaluation)
{
selectedDerivative = await _derivativeResolver.ResolveOptimalTurboAsync(
cleanIsin,
bestSetup.Direction,
bestSetup.InvalidationPrice,
bestSetup.CurrentPrice,
cancellationToken
);
}
// 6. Persist Evaluation Snapshot & Proposal
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var outcomeReason = DetermineOutcomeReason(
aiValidation.IsApproved, scoringResult.PassedEarningsLockout, scoringResult.PassedSimulationVeto,
scoringResult.PassedDividendGate, scoreGateOpened);
var snapshot = new EngineEvaluationSnapshotEntity
{
Id = Guid.NewGuid(),
Isin = cleanIsin,
Symbol = bestSetup.Symbol,
TechnicalScore = scoringResult.TechnicalScore,
SentimentScore = scoringResult.SentimentScore,
FundamentalScore = scoringResult.FundamentalScore,
CompositeOpportunityScore = scoringResult.CompositeScore,
ReliabilityBonus = scoringResult.ReliabilityBonus,
PassedEarningsLockout = scoringResult.PassedEarningsLockout,
DaysToNextEarnings = scoringResult.DaysToNextEarnings,
PassedDividendGate = scoringResult.PassedDividendGate,
DaysToNextExDividend = scoringResult.DaysToNextExDividend,
UniverseSource = bestSetup.UniverseSource,
UniverseEnteredAtUtc = bestSetup.UniverseEnteredAtUtc,
PassedSimulationVeto = scoringResult.PassedSimulationVeto,
PassedAiValidation = aiValidation.IsApproved,
AiThesisSummary = aiValidation.ThesisSummary,
TriggerSource = triggerSource,
TriggeredByUserId = effectiveTriggeredByUserId,
OutcomeReason = outcomeReason,
ProposalId = null,
EvaluatedAtUtc = DateTime.UtcNow
};
db.Snapshots.Add(snapshot);
TradeProposalDto? proposalDto = null;
if (aiValidation.IsApproved)
{
// Dedup guard: OpportunityPollerBackgroundService re-evaluates the same technical top-picks on
// every scan cycle. Without this check, an asset that stays above the approval threshold for hours
// gets a brand-new, near-identical EngineTradeProposalEntity - and a fresh
// finlytic/engine/proposals/created broadcast to every connected client - every single cycle. This
// was confirmed in production as the root cause of a single ISIN generating 1,310 proposal rows in
// roughly two hours. An active, non-expired proposal already covering the same UnderlyingIsin means
// the opportunity is already on offer, so no second row/broadcast is created for it.
var existingActiveProposal = await db.TradeProposals
.AsNoTracking()
.Where(p => p.UnderlyingIsin == cleanIsin && p.IsActive && p.ExpiresAtUtc > DateTime.UtcNow)
.OrderByDescending(p => p.CreatedAtUtc)
.FirstOrDefaultAsync(cancellationToken);
if (existingActiveProposal != null)
{
// The evaluation itself genuinely cleared every gate (PassedAiValidation on this snapshot row
// stays true), but OutcomeReason records the real business outcome: no new proposal was made.
outcomeReason = OutcomeReason.DuplicateActiveProposal;
snapshot.OutcomeReason = outcomeReason;
snapshot.ProposalId = existingActiveProposal.Id;
await db.SaveChangesAsync(cancellationToken);
// A manual "Analyze now" call for an asset that already has an open proposal should still
// surface that proposal, not falsely report "no proposal" (Rules.md §4).
proposalDto = MapProposalEntityToDto(existingActiveProposal);
}
else
{
var proposalValidityHours = await _settingsService.GetSettingAsync(EngineSettingKeys.ProposalValidityHours, cancellationToken);
decimal takeProfit1 = bestSetup.ExitPlan.TakeProfitStages.Count > 0
? bestSetup.ExitPlan.TakeProfitStages[0].TargetPrice
: (bestSetup.Direction == SignalDirection.Buy ? bestSetup.EntryPrice * 1.05m : bestSetup.EntryPrice * 0.95m);
var proposalEntity = new EngineTradeProposalEntity
{
Id = Guid.NewGuid(),
UnderlyingIsin = cleanIsin,
Symbol = bestSetup.Symbol,
StrategyKey = bestSetup.StrategyKey,
Direction = bestSetup.Direction,
QualityScore = bestSetup.QualityScore,
CompositeScore = scoringResult.CompositeScore,
CurrentPrice = bestSetup.CurrentPrice,
EntryPrice = bestSetup.EntryPrice,
StopLoss = bestSetup.InvalidationPrice,
TakeProfit1 = takeProfit1,
RiskRewardRatio = bestSetup.EstimatedRiskRewardRatio,
ExitPlan = bestSetup.ExitPlan,
SelectedDerivative = selectedDerivative,
AiValidation = aiValidation,
IsActive = true,
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddHours(proposalValidityHours)
};
// Link the snapshot row to the proposal it produced (both are still unsaved/tracked here, so
// this just needs to happen before the single SaveChangesAsync below persists both).
snapshot.ProposalId = proposalEntity.Id;
db.TradeProposals.Add(proposalEntity);
await db.SaveChangesAsync(cancellationToken);
proposalDto = MapProposalEntityToDto(proposalEntity);
// Broadcast MQTT Push Event for new proposal
await _rpcClient.PublishAsync("finlytic/engine/proposals/created", proposalDto);
}
}
else
{
await db.SaveChangesAsync(cancellationToken);
}
// Whether approved or rejected, the caller always receives the real, already-computed scores and AI
// reasoning — never bare silence for a rejection (Rules.md §4).
return new AssetEvaluationResultDto(
Proposal: proposalDto,
CompositeScore: scoringResult.CompositeScore,
TechnicalScore: scoringResult.TechnicalScore,
SentimentScore: scoringResult.SentimentScore,
FundamentalScore: scoringResult.FundamentalScore,
PassedEarningsLockout: scoringResult.PassedEarningsLockout,
DaysToNextEarnings: scoringResult.DaysToNextEarnings,
PassedDividendGate: scoringResult.PassedDividendGate,
DaysToNextExDividend: scoringResult.DaysToNextExDividend,
AiApproved: aiValidation.IsApproved,
AiThesisSummary: aiValidation.ThesisSummary,
AiIdentifiedRisks: aiValidation.IdentifiedRisks
);
}
public async Task<ActiveTradeDto?> CreateTradeFromProposalAsync(
Guid userId,
Guid proposalId,
ExecutionMode mode,
decimal? initialFillPrice = null,
decimal? initialQuantity = null,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
// Only a still-active, non-expired proposal may be accepted. Proposals invalidate themselves purely
// via ExpiresAtUtc (see EvaluateAssetAsync) — there is no separate "reject" path that deactivates them.
var now = DateTime.UtcNow;
var proposal = await db.TradeProposals
.FirstOrDefaultAsync(p => p.Id == proposalId && p.IsActive && p.ExpiresAtUtc > now, cancellationToken);
if (proposal == null) return null;
// A proposal is a system-wide opportunity, not a per-user resource: it is deliberately NOT consumed or
// deactivated here so other users may still accept it independently. What must be prevented is the same
// user accepting the same proposal twice, which would otherwise silently create a second, redundant trade.
var alreadyAccepted = await db.Trades
.AnyAsync(t => t.UserId == userId && t.ProposalId == proposalId, cancellationToken);
if (alreadyAccepted)
{
throw new InvalidOperationException(
$"User {userId} has already accepted proposal {proposalId}; a duplicate trade was not created.");
}
var fillPrice = initialFillPrice ?? proposal.EntryPrice;
var fillQty = initialQuantity ?? 1m;
var trade = new EngineTradeEntity
{
Id = Guid.NewGuid(),
UserId = userId,
ProposalId = proposal.Id,
UnderlyingIsin = proposal.UnderlyingIsin,
Symbol = proposal.Symbol,
DerivativeIsin = proposal.SelectedDerivative?.DerivativeIsin,
DerivativeWkn = proposal.SelectedDerivative?.DerivativeWkn,
ExecutionMode = mode,
InstrumentType = proposal.SelectedDerivative != null
? (proposal.Direction == SignalDirection.Buy ? InstrumentCategoryType.TurboLong : InstrumentCategoryType.TurboShort)
: InstrumentCategoryType.Stock,
Direction = proposal.Direction,
Status = TradeStatus.Active,
AverageBuyIn = fillPrice,
TotalQuantity = fillQty,
InitialStopLoss = proposal.StopLoss,
CurrentStopLoss = proposal.StopLoss,
CurrentPrice = fillPrice,
TakeProfit1 = proposal.TakeProfit1,
TakeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice : proposal.TakeProfit1 * 1.05m,
ExitPlan = proposal.ExitPlan,
OpenedAtUtc = DateTime.UtcNow,
LastUpdatedAtUtc = DateTime.UtcNow
};
var initialFill = new EngineTradeFillEntity
{
Id = Guid.NewGuid(),
TradeId = trade.Id,
Trade = trade,
ExecutedAtUtc = DateTime.UtcNow,
Price = fillPrice,
Quantity = fillQty,
Fee = 1.0m,
Note = "Initial Entry Fill"
};
// trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph
// (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site
// consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there).
trade.Fills.Add(initialFill);
db.Trades.Add(trade);
db.TradeFills.Add(initialFill);
await db.SaveChangesAsync(cancellationToken);
var tradeDto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", tradeDto);
return tradeDto;
}
public async Task<ActiveTradeDto> AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default)
{
// ExecutionMode.ManualTradeRepublic is hardcoded here (rather than taken from the request) because this
// RPC channel exists specifically for the human-driven Web/App acceptance flow, where a user reviews a
// proposal in Trade Republic and confirms a manual fill. The autonomous paper-trading bot never calls
// this endpoint — it executes proposals itself via FinlyticBot, which uses its own dedicated code path
// instead of AcceptProposalAsync.
var trade = await CreateTradeFromProposalAsync(
request.UserId,
request.ProposalId,
ExecutionMode.ManualTradeRepublic,
request.ExecutedPrice,
request.Quantity,
cancellationToken);
if (trade == null)
{
throw new InvalidOperationException(
$"Proposal {request.ProposalId} does not exist, is no longer active, or has expired.");
}
return trade;
}
public async Task<ActiveTradeDto> CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.UnderlyingIsin))
{
throw new ArgumentException("UnderlyingIsin must not be blank.", nameof(request));
}
if (string.IsNullOrWhiteSpace(request.Symbol))
{
throw new ArgumentException("Symbol must not be blank.", nameof(request));
}
if (request.EntryPrice <= 0m)
{
throw new ArgumentException("EntryPrice must be positive.", nameof(request));
}
if (request.Quantity <= 0m)
{
throw new ArgumentException("Quantity must be positive.", nameof(request));
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var takeProfit1 = request.TakeProfit1;
var takeProfit2 = request.TakeProfit2 ?? takeProfit1;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: request.InitialStopLoss,
TakeProfitStages: new List<TakeProfitStage>
{
new(StageNumber: 1, TargetPrice: takeProfit1, PercentToClose: 100m, RMultiple: 1m, Description: "Manuelles Kursziel (kein Proposal)")
});
var trade = new EngineTradeEntity
{
Id = Guid.NewGuid(),
UserId = request.UserId,
// No backing proposal: Guid.Empty signals "manually opened" (see doc comment on
// CreateManualTradeRequest / ITradeLifecycleService.CreateManualTradeAsync).
ProposalId = Guid.Empty,
UnderlyingIsin = request.UnderlyingIsin.Trim().ToUpperInvariant(),
Symbol = request.Symbol,
DerivativeIsin = request.DerivativeIsin,
DerivativeWkn = request.DerivativeWkn,
ExecutionMode = ExecutionMode.ManualTradeRepublic,
InstrumentType = request.InstrumentType,
Direction = request.Direction,
Status = TradeStatus.Active,
AverageBuyIn = request.EntryPrice,
TotalQuantity = request.Quantity,
InitialStopLoss = request.InitialStopLoss,
CurrentStopLoss = request.InitialStopLoss,
CurrentPrice = request.EntryPrice,
TakeProfit1 = takeProfit1,
TakeProfit2 = takeProfit2,
TotalFeesEur = request.Fee,
ExitPlan = exitPlan,
OpenedAtUtc = DateTime.UtcNow,
LastUpdatedAtUtc = DateTime.UtcNow
};
var initialFill = new EngineTradeFillEntity
{
Id = Guid.NewGuid(),
TradeId = trade.Id,
Trade = trade,
ExecutedAtUtc = DateTime.UtcNow,
Price = request.EntryPrice,
Quantity = request.Quantity,
Fee = request.Fee,
Note = "Manual Entry (no proposal)"
};
// trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph
// (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site
// consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there).
trade.Fills.Add(initialFill);
db.Trades.Add(trade);
db.TradeFills.Add(initialFill);
await db.SaveChangesAsync(cancellationToken);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
public async Task<ActiveTradeDto> AddTradeFillAsync(
Guid userId,
Guid tradeId,
decimal executedPrice,
decimal quantity,
decimal fee = 0m,
string? note = null,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var trade = await db.Trades
.Include(t => t.Fills)
.FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken);
if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found.");
var fill = new EngineTradeFillEntity
{
Id = Guid.NewGuid(),
TradeId = trade.Id,
Trade = trade,
ExecutedAtUtc = DateTime.UtcNow,
Price = executedPrice,
Quantity = quantity,
Fee = fee,
Note = note
};
// Explicitly track the new fill as Added via the DbSet, not just via collection-navigation fixup.
// A fill's Id is a client-generated Guid (set above), so if this entity only entered the change
// tracker through `trade.Fills.Add(fill)` on an already-tracked trade, EF Core cannot use "default
// key value => Added" as its heuristic (the key is never default) and instead discovers the object as
// Unchanged, then promotes it to Modified once DetectChanges sees its properties differ from nothing —
// producing an UPDATE for a row that was never inserted (DbUpdateConcurrencyException: 0 rows
// affected). db.TradeFills.Add(fill) marks it Added unambiguously; trade.Fills.Add(fill) is still
// needed so the in-memory graph/DTO mapping below sees the new fill.
db.TradeFills.Add(fill);
trade.Fills.Add(fill);
// Recalculate Dynamic Average Buy-In: Sum(P * Q) / Sum(Q)
decimal totalValue = trade.Fills.Sum(f => f.Price * f.Quantity);
decimal totalQty = trade.Fills.Sum(f => f.Quantity);
if (totalQty > 0)
{
trade.AverageBuyIn = Math.Round(totalValue / totalQty, 4);
trade.TotalQuantity = totalQty;
}
trade.TotalFeesEur = trade.Fills.Sum(f => f.Fee);
trade.Status = TradeStatus.Active;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
// Recalculate Dynamic R-Levels & Take-Profits based on new AverageBuyIn
decimal unitRisk = Math.Abs(trade.AverageBuyIn - trade.InitialStopLoss);
if (unitRisk > 0)
{
if (trade.Direction == SignalDirection.Buy)
{
trade.TakeProfit1 = trade.AverageBuyIn + (1.0m * unitRisk);
trade.TakeProfit2 = trade.AverageBuyIn + (2.0m * unitRisk);
}
else
{
trade.TakeProfit1 = trade.AverageBuyIn - (1.0m * unitRisk);
trade.TakeProfit2 = trade.AverageBuyIn - (2.0m * unitRisk);
}
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[TradeLifecycle] Fill added to trade {TradeId}: Qty={Qty}, Price={Price:F2}, New AverageBuyIn={BuyIn:F4}, TotalQty={TotalQty}",
trade.Id, quantity, executedPrice, trade.AverageBuyIn, trade.TotalQuantity);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
public async Task<ActiveTradeDto> UpdateStopLossAsync(
Guid userId,
Guid tradeId,
decimal newStopLoss,
string reason,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var trade = await db.Trades
.Include(t => t.Fills)
.FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken);
if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found.");
decimal oldSl = trade.CurrentStopLoss;
trade.CurrentStopLoss = newStopLoss;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[TradeLifecycle] Stop Loss updated for trade {TradeId} from {OldSl:F2} to {NewSl:F2}. Reason: {Reason}",
trade.Id, oldSl, newStopLoss, reason);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
public async Task<ActiveTradeDto> CloseTradeAsync(
Guid userId,
Guid tradeId,
decimal closePrice,
string reason,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var trade = await db.Trades
.Include(t => t.Fills)
.FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken);
if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found.");
trade.Status = TradeStatus.Closed;
trade.ClosedAtUtc = DateTime.UtcNow;
trade.CurrentPrice = closePrice;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
// Realized PnL Calculation
if (trade.Direction == SignalDirection.Buy)
{
trade.RealizedPnlEur = ((closePrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur;
}
else
{
trade.RealizedPnlEur = ((trade.AverageBuyIn - closePrice) * trade.TotalQuantity) - trade.TotalFeesEur;
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[TradeLifecycle] Trade {TradeId} closed at {Price:F2} (PnL: {PnL:F2} €). Reason: {Reason}",
trade.Id, closePrice, trade.RealizedPnlEur, reason);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
private static TradeProposalDto MapProposalEntityToDto(EngineTradeProposalEntity e)
{
return new TradeProposalDto(
ProposalId: e.Id,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
StrategyKey: e.StrategyKey,
Direction: e.Direction,
QualityScore: e.QualityScore,
CompositeScore: e.CompositeScore,
CurrentPrice: e.CurrentPrice,
EntryPrice: e.EntryPrice,
InvalidationPrice: e.StopLoss,
ExitPlan: e.ExitPlan,
SelectedDerivative: e.SelectedDerivative,
AiValidation: e.AiValidation,
CreatedAtUtc: e.CreatedAtUtc,
ExpiresAtUtc: e.ExpiresAtUtc
);
}
private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e)
{
decimal unrealizedPnlEur = 0m;
decimal unrealizedPnlPercent = 0m;
if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0)
{
if (e.Direction == SignalDirection.Buy)
{
unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity;
unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m;
}
else
{
unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity;
unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m;
}
}
return new ActiveTradeDto(
TradeId: e.Id,
ProposalId: e.ProposalId,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin,
DerivativeWkn: e.DerivativeWkn,
ExecutionMode: e.ExecutionMode,
InstrumentType: e.InstrumentType,
Direction: e.Direction,
Status: e.Status,
AverageBuyIn: e.AverageBuyIn,
TotalQuantity: e.TotalQuantity,
InitialStopLoss: e.InitialStopLoss,
CurrentStopLoss: e.CurrentStopLoss,
CurrentPrice: e.CurrentPrice,
UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2),
UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2),
RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2),
ExitPlan: e.ExitPlan,
Fills: e.Fills.Select(f => new TradeFillDto(
FillId: f.Id,
ExecutedAtUtc: f.ExecutedAtUtc,
Price: f.Price,
Quantity: f.Quantity,
Fee: f.Fee,
Note: f.Note
)).ToList(),
OpenedAtUtc: e.OpenedAtUtc,
ClosedAtUtc: e.ClosedAtUtc
);
}
}