Files
Finlytic/FinlyticBackend/Controllers/EngineController.cs
T

282 lines
11 KiB
C#

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." });
}
}
}