feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user