feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticBackend.Util;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticBackend.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/v1/engine")]
|
||||
[EnableCors("AllowAll")]
|
||||
public class EngineController : ControllerBase
|
||||
{
|
||||
private readonly WebMqttClient _mqttClient;
|
||||
private readonly ILogger<EngineController> _logger;
|
||||
|
||||
public EngineController(WebMqttClient mqttClient, ILogger<EngineController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>. There is no pseudo-identity fallback: 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.
|
||||
/// </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("[EngineController] 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 active or all AI-validated trade proposals generated by FinlyticEngine. Proposals are
|
||||
/// system-wide opportunities owned by no user, so this action is not scoped to the caller's identity.
|
||||
/// </summary>
|
||||
[HttpGet("proposals")]
|
||||
public async Task<IActionResult> GetProposals([FromQuery] bool onlyActive = true, [FromQuery] int limit = 50)
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new GetTradeProposalsRequest(onlyActive, limit);
|
||||
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EngineController] Failed to retrieve trade proposals via MQTT RPC.");
|
||||
return StatusCode(500, new { error = "Internal server error fetching trade proposals." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves active trades (positions) owned by the authenticated caller and being tracked or managed by
|
||||
/// FinlyticEngine.
|
||||
/// </summary>
|
||||
[HttpGet("trades")]
|
||||
public async Task<IActionResult> GetActiveTrades([FromQuery] ExecutionMode? mode = null)
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
var request = new GetActiveTradesRequest(UserId: userId, Mode: mode);
|
||||
var trades = await _mqttClient.SendRpcRequestAsync<List<ActiveTradeDto>, GetActiveTradesRequest>(
|
||||
"engine_GetTrades", request, 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, "[EngineController] Failed to retrieve active trades via MQTT RPC.");
|
||||
return StatusCode(500, new { error = "Internal server error fetching active trades." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers an immediate full-pipeline evaluation (FTA + Sentiment + Fundamentals + AI Gate + Knock-Out Resolver) for an asset.
|
||||
/// Always returns <c>200 OK</c> with a full <see cref="AssetEvaluationResultDto"/> body — including the real
|
||||
/// scores and AI reasoning when the evaluation did not clear the bar for a proposal (<c>Proposal == null</c>),
|
||||
/// instead of the previous anonymous placeholder object (Rules.md §3/§4).
|
||||
/// </summary>
|
||||
[HttpPost("evaluate")]
|
||||
public async Task<IActionResult> EvaluateAsset([FromBody] EvaluateAssetRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Isin))
|
||||
{
|
||||
return BadRequest(new { error = "Mandatory ISIN parameter is missing." });
|
||||
}
|
||||
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// Any UserId supplied by the client in the body is discarded and replaced with the JWT identity,
|
||||
// exactly like every other engine request DTO with a UserId field (see EvaluateAssetRequest's doc
|
||||
// comment) - it is never trusted from the client.
|
||||
var req = request with { UserId = userId };
|
||||
|
||||
var evaluation = await _mqttClient.SendRpcRequestAsync<AssetEvaluationResultDto, EvaluateAssetRequest>(
|
||||
"engine_EvaluateIsin", req, TimeSpan.FromSeconds(15));
|
||||
|
||||
if (evaluation == null)
|
||||
{
|
||||
// Null means the RPC call itself got no response (transport failure), not a legitimate
|
||||
// "evaluated, no proposal" outcome - that case is now always a populated DTO.
|
||||
return StatusCode(502, new { error = "FinlyticEngine did not respond to the evaluation request." });
|
||||
}
|
||||
|
||||
return Ok(evaluation);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EngineController] Failed to evaluate asset {Isin} via MQTT RPC.", request.Isin);
|
||||
return StatusCode(500, new { error = "Internal server error evaluating asset." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an executed fill (partial buy or scale-in) to an existing active trade owned by the authenticated
|
||||
/// caller, triggering dynamic buy-in recalculation.
|
||||
/// </summary>
|
||||
[HttpPost("trades/{tradeId:guid}/fills")]
|
||||
public async Task<IActionResult> AddTradeFill(Guid tradeId, [FromBody] AddTradeFillRequest request)
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// Any UserId supplied by the client in the body is discarded and replaced with the JWT identity,
|
||||
// so a caller can never add a fill to another user's trade.
|
||||
var req = request with { UserId = userId, TradeId = tradeId };
|
||||
var updatedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, AddTradeFillRequest>(
|
||||
"engine_AddFill", req, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (updatedTrade != null)
|
||||
{
|
||||
return Ok(updatedTrade);
|
||||
}
|
||||
|
||||
return NotFound(new { error = $"Trade {tradeId} not found." });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EngineController] Failed to add fill to trade {TradeId}.", tradeId);
|
||||
return StatusCode(500, new { error = "Internal server error adding trade fill." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually or algorithmically adjusts the Stop-Loss of an active trade owned by the authenticated caller.
|
||||
/// </summary>
|
||||
[HttpPut("trades/{tradeId:guid}/stoploss")]
|
||||
public async Task<IActionResult> UpdateStopLoss(Guid tradeId, [FromBody] UpdateTradeStopLossRequest request)
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// Any UserId supplied by the client in the body is discarded and replaced with the JWT identity.
|
||||
var req = request with { UserId = userId, TradeId = tradeId };
|
||||
var updatedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, UpdateTradeStopLossRequest>(
|
||||
"engine_UpdateStopLoss", req, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (updatedTrade != null)
|
||||
{
|
||||
return Ok(updatedTrade);
|
||||
}
|
||||
|
||||
return NotFound(new { error = $"Trade {tradeId} not found." });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EngineController] Failed to update stop loss for trade {TradeId}.", tradeId);
|
||||
return StatusCode(500, new { error = "Internal server error updating stop loss." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes an active trade owned by the authenticated caller at a specific market or exit price.
|
||||
/// </summary>
|
||||
[HttpPost("trades/{tradeId:guid}/close")]
|
||||
public async Task<IActionResult> CloseTrade(Guid tradeId, [FromBody] CloseEngineTradeRequest request)
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// Any UserId supplied by the client in the body is discarded and replaced with the JWT identity.
|
||||
var req = request with { UserId = userId, TradeId = tradeId };
|
||||
var closedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, CloseEngineTradeRequest>(
|
||||
"engine_CloseTrade", req, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (closedTrade != null)
|
||||
{
|
||||
return Ok(closedTrade);
|
||||
}
|
||||
|
||||
return NotFound(new { error = $"Trade {tradeId} not found." });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Unauthorized(new { error = "A valid user identity is required." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EngineController] Failed to close trade {TradeId}.", tradeId);
|
||||
return StatusCode(500, new { error = "Internal server error closing trade." });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user