feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware

This commit is contained in:
2026-08-24 21:37:32 +02:00
parent 6974b2075b
commit 676496b77d
37 changed files with 88203 additions and 83075 deletions
+78 -110
View File
@@ -1,17 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades;
using FinlyticCore.Dtos.Trading;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -40,152 +37,123 @@ public record AnalyzeRequest(
[EnableCors("AllowAll")]
public class AnalyzeController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly BackendMqttBridge _mqttClient;
private readonly ILogger<AnalyzeController> _logger;
public AnalyzeController(WebMqttClient mqttClient, ILogger<AnalyzeController> logger)
public AnalyzeController(BackendMqttBridge mqttClient, ILogger<AnalyzeController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Triggers a manual analysis for an asset by gathering context in parallel and dispatching to FinlyticAnalyzer.
/// Resolves the authenticated caller's identity from the JWT <c>NameIdentifier</c> claim, exactly like
/// <c>EngineController.GetUserIdFromClaims</c>. Every manual evaluation triggered through this controller
/// is tagged with this identity as <c>EngineEvaluationSnapshotEntity.TriggeredByUserId</c>, so a request
/// whose identity cannot be established must be rejected rather than silently recorded as anonymous.
/// </summary>
/// <exception cref="UnauthorizedAccessException">
/// The <c>NameIdentifier</c> claim (or its <c>sub</c>/<c>nameid</c> fallbacks) is missing or is not a
/// parseable <see cref="Guid"/>.
/// </exception>
private Guid GetUserIdFromClaims()
{
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")
?? User.FindFirstValue("nameid");
if (Guid.TryParse(claimUserId, out var userId))
{
return userId;
}
_logger.LogWarning("[AnalyzeController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
}
/// <summary>
/// Triggers an on-demand analysis for an asset via FinlyticEngine.
/// Always returns <c>200 OK</c> with a full <see cref="AssetEvaluationResultDto"/> body — the analysis
/// pipeline now reports the real, already-computed scores and AI reasoning even when it did not produce a
/// proposal (<c>Proposal == null</c>), so there is no longer a "silent" <c>204 No Content</c> outcome for a
/// completed-but-rejected evaluation (Rules.md §4). <c>204</c> is gone entirely: the only remaining failure
/// modes (missing ISIN/Symbol, unreachable engine, no RPC response, unexpected error) are standardized
/// Problem Details (Rules.md §11) instead of anonymous status objects.
/// </summary>
[HttpPost("manual")]
public async Task<IActionResult> TriggerManualAnalysis([FromBody] AnalyzeRequest request)
{
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
{
return Problem(title: "ISIN or Symbol is required.", statusCode: StatusCodes.Status400BadRequest);
}
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
{
return BadRequest(new { error = "ISIN or Symbol is required" });
}
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "Analysis service is currently unavailable." });
}
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
string targetSymbol = (request.Symbol ?? request.Isin ?? string.Empty).Trim().ToUpperInvariant();
var isinReq = new IsinRequest(targetIsin);
// 1. Parallelisiertes Context-Gathering (TA, Fundamentals, Sentiment) für minimale Latenz
var taTask = FetchTaDataAsync(isinReq);
var fundTask = FetchFundamentalsDataAsync(isinReq);
var sentTask = FetchSentimentDataAsync(isinReq);
// UserId always comes from the JWT, never from the request body - matches every other engine
// request DTO with a UserId field (see EvaluateAssetRequest's doc comment).
var userId = GetUserIdFromClaims();
await Task.WhenAll(taTask, fundTask, sentTask);
var taData = await taTask;
var fundData = await fundTask;
var sentData = await sentTask;
decimal lastCandlePrice = taData?.Candles != null && taData.Candles.Count > 0 ? (decimal)taData.Candles.Last().Close : 0m;
decimal resolvedPrice = request.CurrentPrice ?? (lastCandlePrice > 0 ? lastCandlePrice : 100.0m);
var rpcRequest = new ManualAnalysisRpcRequest(
var evalRequest = new EvaluateAssetRequest(
Isin: targetIsin,
Symbol: targetSymbol,
Sector: !string.IsNullOrWhiteSpace(request.Sector) ? request.Sector : "Technology",
Headline: !string.IsNullOrWhiteSpace(request.Headline) ? request.Headline : "Manual Analysis Triggered by User",
CurrentPrice: resolvedPrice,
RiskScore: request.RiskScore ?? 50,
MinTimeframeValue: request.MinTimeframeValue ?? 4,
MaxTimeframeValue: request.MaxTimeframeValue ?? 6,
TimeframeUnit: !string.IsNullOrWhiteSpace(request.TimeframeUnit) ? request.TimeframeUnit : "Tage",
InstrumentType: !string.IsNullOrWhiteSpace(request.InstrumentType) ? request.InstrumentType : "Stock",
UserNotes: request.UserNotes ?? string.Empty,
TaData: taData,
FundamentalsData: fundData,
SentimentData: sentData
UserId: userId,
Ticker: null,
ForceAiEvaluation: true
);
// 2. Ausführen des RPC Triggers am FinlyticAnalyzer (mit typisierter Response)
try
{
var response = await _mqttClient.SendRpcRequestAsync<ManualAnalysisResponseDto, ManualAnalysisRpcRequest>(
"analyzer_TriggerManual", rpcRequest, TimeSpan.FromSeconds(10));
var evaluation = await _mqttClient.SendRpcRequestAsync<AssetEvaluationResultDto, EvaluateAssetRequest>(
"engine_EvaluateIsin", evalRequest, TimeSpan.FromSeconds(15));
if (response != null)
{
return Ok(response);
}
}
catch (Exception ex)
if (evaluation == null)
{
_logger.LogWarning(ex, "[AnalyzeController] RPC analyzer_TriggerManual timed out or failed for ISIN '{Isin}'.", targetIsin);
// Null here means the RPC call itself timed out / got no response - a transport failure, not
// a legitimate "evaluated, no proposal" business outcome (that is now always a populated DTO).
return Problem(title: "FinlyticEngine did not respond to the evaluation request.", statusCode: StatusCodes.Status502BadGateway);
}
return Ok(new
{
status = "AnalysisTriggered",
isin = rpcRequest.Isin,
message = "Manuelle KI-Analyse wurde gestartet, verarbeitet Ergebnisse im Hintergrund."
});
return Ok(evaluation);
}
catch (UnauthorizedAccessException)
{
return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger manual analysis via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error triggering analysis." });
_logger.LogError(ex, "Failed to trigger manual analysis via FinlyticEngine.");
return Problem(title: "Internal server error triggering analysis.", statusCode: StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Fetches the currently active trade proposals from the FinlyticTrades service.
/// Fetches the currently active trade proposals from FinlyticEngine.
/// </summary>
[HttpGet("proposals")]
public async Task<IActionResult> GetActiveProposals()
{
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "Analysis service is currently unavailable." });
}
var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
// AOT-sicherer RPC-Aufruf an FinlyticTrades für vorgeschlagene Trades
var request = new GetTradesRequest(Isin: null, Status: "Proposed", UserId: null);
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get", request, TimeSpan.FromSeconds(4));
return Ok(proposals ?? new List<TradeProposalDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch active proposals via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error fetching proposals." });
_logger.LogError(ex, "Failed to fetch active proposals from FinlyticEngine.");
return Problem(title: "Internal server error fetching proposals.", statusCode: StatusCodes.Status500InternalServerError);
}
}
private async Task<TechnicalAnalysisDto?> FetchTaDataAsync(IsinRequest request)
{
try
{
return await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
"ta_GetAnalysis", request, TimeSpan.FromSeconds(2));
}
catch { return null; }
}
private async Task<AssetFundamentalsDto?> FetchFundamentalsDataAsync(IsinRequest request)
{
try
{
return await _mqttClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
"fundamentals_Get", request, TimeSpan.FromSeconds(2));
}
catch { return null; }
}
private async Task<IsinSentimentSummaryDto?> FetchSentimentDataAsync(IsinRequest request)
{
try
{
return await _mqttClient.SendRpcRequestAsync<IsinSentimentSummaryDto, IsinRequest>(
"sentiment_GetIsin", request, TimeSpan.FromSeconds(2));
}
catch { return null; }
}
}