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
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Models.Trades;
using FinlyticCore.Dtos.Trading;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -16,35 +18,45 @@ namespace FinlyticBackend.Controllers;
[Route("api/v1/user/trades")]
public class UserTradesController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly BackendMqttBridge _mqttClient;
private readonly ILogger<UserTradesController> _logger;
public UserTradesController(WebMqttClient mqttClient, ILogger<UserTradesController> logger)
public UserTradesController(BackendMqttBridge mqttClient, ILogger<UserTradesController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Liest die eindeutige UserId aus den Claims des authentifizierten Bearer Tokens.
/// Resolves the authenticated caller's identity from the JWT <c>NameIdentifier</c> claim, parsed as a
/// <see cref="Guid"/> exactly like the global user-validation middleware in
/// <c>FinlyticBackend/Program.cs</c> (see line ~146). There is deliberately no pseudo-identity fallback
/// (e.g. a shared "default_user" string): every trade in FinlyticEngine is tenant-scoped by
/// <c>EngineTradeEntity.UserId</c>, so a request whose identity cannot be established must be rejected
/// with 401 rather than silently attributed to a placeholder user that could leak or merge data across
/// tenants.
/// </summary>
private string GetUserIdFromClaims()
/// <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")
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")
?? User.FindFirstValue("nameid");
if (!string.IsNullOrWhiteSpace(claimUserId))
if (Guid.TryParse(claimUserId, out var userId))
{
return claimUserId;
return userId;
}
_logger.LogWarning("[UserTradesController] Claim NameIdentifier not found for authenticated request. Falling back to default_user.");
return "default_user";
_logger.LogWarning("[UserTradesController] 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>
/// Retrieves a list of trades for the current authenticated user (including global proposals).
/// Retrieves a list of active trades or proposals from FinlyticEngine.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
@@ -53,116 +65,186 @@ public class UserTradesController : ControllerBase
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
}
var userId = GetUserIdFromClaims();
// FIX: UserId explizit an GetTradesRequest übergeben!
var request = new GetTradesRequest(isin, status, userId);
var trades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get",
request,
TimeSpan.FromSeconds(5));
return Ok(trades ?? new List<TradeProposalDto>());
if (string.Equals(status, "Proposed", StringComparison.OrdinalIgnoreCase))
{
// Proposals are system-wide opportunities, not owned by any user, so no UserId is attached here.
var req = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
"engine_GetProposals", req, TimeSpan.FromSeconds(5));
return Ok(proposals ?? new List<TradeProposalDto>());
}
else
{
var userId = GetUserIdFromClaims();
var req = new GetActiveTradesRequest(UserId: userId, Mode: null);
var trades = await _mqttClient.SendRpcRequestAsync<List<ActiveTradeDto>, GetActiveTradesRequest>(
"engine_GetTrades", req, TimeSpan.FromSeconds(5));
return Ok(trades ?? new List<ActiveTradeDto>());
}
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new { error = "A valid user identity is required." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve user trades via MQTT RPC.");
_logger.LogError(ex, "Failed to retrieve trades from FinlyticEngine.");
return StatusCode(500, new { error = "Internal server error while fetching trades" });
}
}
/// <summary>
/// Public endpoint to retrieve active global proposals for guest users.
/// </summary>
[HttpGet("/api/v1/trades/public")]
[AllowAnonymous]
public async Task<IActionResult> GetPublicProposals([FromQuery] string? isin = null)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
// Status = Proposed für anonyme Öffentliche Anfragen
var request = new GetTradesRequest(isin, "Proposed", UserId: null);
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get",
request,
TimeSpan.FromSeconds(5));
return Ok(proposals ?? new List<TradeProposalDto>());
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to retrieve public trade proposals via MQTT RPC.");
return Ok(new List<TradeProposalDto>());
}
}
/// <summary>
/// Accepts a proposed trade and assigns it to the current user's portfolio.
/// Accepts a trade proposal and converts it into an actively tracked trade in FinlyticEngine, owned by the
/// authenticated caller.
/// </summary>
/// <param name="dto">
/// The acceptance payload sent by the client (see <c>TradeRepository.acceptTrade</c> in FinlyticApp),
/// carrying the proposal identifier (<see cref="FinlyticCore.Models.Trades.TradeAcceptanceDto.TradeId"/>)
/// plus any user-adjusted position sizing, leverage and fee details. Any <c>userId</c> supplied by the
/// client is discarded; the acceptance is always attributed to the identity from the JWT.
/// </param>
/// <returns>
/// The resulting <see cref="ActiveTradeDto"/> on success, or a diagnostic error response if
/// FinlyticEngine is unreachable or rejects the request (e.g. proposal expired, not found, or already
/// accepted by this same user).
/// </returns>
[HttpPost("accept")]
public async Task<IActionResult> AcceptTrade([FromBody] TradeAcceptanceDto request)
public async Task<IActionResult> AcceptTrade([FromBody] FinlyticCore.Models.Trades.TradeAcceptanceDto dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.TradeId) || !Guid.TryParse(dto.TradeId, out var proposalId))
{
return BadRequest(new { error = "A valid proposal id ('tradeId') is required." });
}
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
}
try
{
if (!_mqttClient.IsConnected)
// The acceptance is always attributed to the authenticated caller, never to a client-supplied value.
var userId = GetUserIdFromClaims();
var acceptRequest = new AcceptTradeProposalRequest(
UserId: userId,
ProposalId: proposalId,
ExecutedPrice: dto.ActualEntryPrice ?? dto.EntryPrice,
Quantity: dto.Quantity ?? dto.PositionSize);
var acceptedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, AcceptTradeProposalRequest>(
"engine_AcceptProposal", acceptRequest, TimeSpan.FromSeconds(10));
if (acceptedTrade != null)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
return Ok(acceptedTrade);
}
if (string.IsNullOrWhiteSpace(request.AnalysisId) && string.IsNullOrWhiteSpace(request.TradeId))
{
return BadRequest(new { error = "AnalysisId or TradeId is required" });
}
// FIX: UserId felsenfest aus den authentifizierten Claims überschreiben
request.UserId = GetUserIdFromClaims();
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, TradeAcceptanceDto>(
"trades_Accept",
request,
TimeSpan.FromSeconds(5));
if (result != null)
{
return Ok(result);
}
// Fallback Fire-and-Forget
await _mqttClient.PublishAsync($"finlytic/trades/accept/{request.Isin}", request);
return Ok(new { status = "Accepted", analysisId = request.AnalysisId, tradeId = request.TradeId, userId = request.UserId });
return StatusCode(504, new { error = "FinlyticEngine did not confirm the trade acceptance in time." });
}
catch (Exception ex)
catch (UnauthorizedAccessException)
{
_logger.LogError(ex, "Failed to accept trade proposal via MQTT RPC.");
return StatusCode(500, new { error = "Internal server error" });
return Unauthorized(new { error = "A valid user identity is required." });
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Cannot accept proposal {ProposalId}: rejected by FinlyticEngine (expired, not found, or already accepted by this user).", proposalId);
return Conflict(new { error = "The proposal is no longer available or has already been accepted by this user." });
}
catch (JsonException ex)
{
_logger.LogError(ex, "Malformed RPC response while accepting proposal {ProposalId}.", proposalId);
return StatusCode(502, new { error = "FinlyticEngine returned an unexpected response while accepting the trade." });
}
}
/// <summary>
/// Closes an active trade.
/// Manually opens a trade in FinlyticEngine with no backing proposal (e.g. the user enters a position in
/// the Web UI that was never evaluated/scored by FinlyticEngine). This is the alternative to
/// <see cref="AcceptTrade"/>: a user either accepts an existing proposal or creates a trade from scratch,
/// and both paths end up with one user-owned <see cref="ActiveTradeDto"/>.
/// </summary>
[HttpPost("{id}/close")]
public async Task<IActionResult> CloseTrade(string id, [FromBody] CloseTradeRequest? request)
/// <param name="request">
/// The manual trade payload from the client. Any <c>userId</c> it carries is discarded; the trade is
/// always attributed to the identity from the JWT.
/// </param>
[HttpPost("manual")]
public async Task<IActionResult> CreateManualTrade([FromBody] CreateManualTradeRequest request)
{
if (request == null)
{
return Problem(title: "A request body is required.", statusCode: StatusCodes.Status400BadRequest);
}
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
// The trade is always attributed to the authenticated caller, never to a client-supplied value.
var userId = GetUserIdFromClaims();
var manualTradeRequest = request with { UserId = userId };
var trade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, CreateManualTradeRequest>(
"engine_CreateManualTrade", manualTradeRequest, TimeSpan.FromSeconds(10));
if (trade != null)
{
return Ok(trade);
}
return Problem(title: "FinlyticEngine did not confirm the manual trade in time.", statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (UnauthorizedAccessException)
{
return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Rejected manual trade creation: invalid request payload.");
return Problem(title: "The manual trade payload is invalid.", detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Malformed RPC response while creating a manual trade.");
return Problem(title: "FinlyticEngine returned an unexpected response while creating the manual trade.", statusCode: StatusCodes.Status502BadGateway);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create manual trade via FinlyticEngine.");
return Problem(title: "Internal server error while creating the manual trade.", statusCode: StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Closes an active trade via FinlyticEngine. The trade must be owned by the authenticated caller;
/// FinlyticEngine enforces this server-side and reports an unknown/foreign trade identically (Rules.md
/// multi-tenancy requirement), so this action never leaks whether a trade ID belongs to another user.
/// </summary>
[HttpPost("{id:guid}/close")]
public async Task<IActionResult> CloseTrade(Guid id, [FromBody] CloseEngineTradeRequest? request)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
}
var closeReq = request ?? new CloseTradeRequest { UserExitPrice = 100.0m, CloseReason = "UserManualClose" };
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
$"trades_Close/{id}",
var userId = GetUserIdFromClaims();
// Any UserId/TradeId supplied by the client in the body is discarded and replaced with the
// authenticated identity and the route value, so a caller cannot target another user's trade.
var closeReq = (request ?? new CloseEngineTradeRequest(UserId: userId, TradeId: id, ClosePrice: 0m, Reason: "UserManualClose"))
with { UserId = userId, TradeId = id };
var result = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, CloseEngineTradeRequest>(
"engine_CloseTrade",
closeReq,
TimeSpan.FromSeconds(5));
@@ -171,45 +253,16 @@ public class UserTradesController : ControllerBase
return Ok(result);
}
return Ok(new { status = "Closed", tradeId = id });
return NotFound(new { error = $"Trade {id} not found." });
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new { error = "A valid user identity is required." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to close trade {Id} via MQTT RPC.", id);
_logger.LogError(ex, "Failed to close trade {Id} via FinlyticEngine.", id);
return StatusCode(500, new { error = "Internal server error while closing trade" });
}
}
/// <summary>
/// Rejects a proposed trade.
/// </summary>
[HttpPost("{id}/reject")]
public async Task<IActionResult> RejectTrade(string id, [FromBody] CloseTradeRequest? request)
{
try
{
if (!_mqttClient.IsConnected)
{
return StatusCode(503, new { error = "MQTT Broker disconnected" });
}
var closeReq = request ?? new CloseTradeRequest { CloseReason = "UserRejected" };
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
$"trades_Reject/{id}",
closeReq,
TimeSpan.FromSeconds(5));
if (result != null)
{
return Ok(result);
}
return Ok(new { status = "Rejected", tradeId = id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reject trade {Id} via MQTT RPC.", id);
return StatusCode(500, new { error = "Internal server error while rejecting trade" });
}
}
}
}