367 lines
19 KiB
C#
367 lines
19 KiB
C#
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
|
|
);
|
|
}
|
|
}
|