269 lines
12 KiB
C#
269 lines
12 KiB
C#
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.Dtos.Trading;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace FinlyticBackend.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/v1/user/trades")]
|
|
public class UserTradesController : ControllerBase
|
|
{
|
|
private readonly BackendMqttBridge _mqttClient;
|
|
private readonly ILogger<UserTradesController> _logger;
|
|
|
|
public UserTradesController(BackendMqttBridge mqttClient, ILogger<UserTradesController> 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> (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>
|
|
/// <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("[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 active trades or proposals from FinlyticEngine.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
|
|
{
|
|
try
|
|
{
|
|
if (!_mqttClient.IsConnected)
|
|
{
|
|
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
|
|
}
|
|
|
|
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 trades from FinlyticEngine.");
|
|
return StatusCode(500, new { error = "Internal server error while fetching trades" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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] 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
|
|
{
|
|
// 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 Ok(acceptedTrade);
|
|
}
|
|
|
|
return StatusCode(504, new { error = "FinlyticEngine did not confirm the trade acceptance in time." });
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
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>
|
|
/// 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>
|
|
/// <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 = "FinlyticEngine is currently unreachable." });
|
|
}
|
|
|
|
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));
|
|
|
|
if (result != null)
|
|
{
|
|
return Ok(result);
|
|
}
|
|
|
|
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 FinlyticEngine.", id);
|
|
return StatusCode(500, new { error = "Internal server error while closing trade" });
|
|
}
|
|
}
|
|
}
|