feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user