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
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
/// <summary>
/// Admin-only Web UI endpoint over FinlyticEngine's persisted evaluation history
/// (<c>engine_evaluation_snapshots</c>), answering "why hasn't a new trade proposal appeared" by exposing every
/// evaluation the engine ever ran - approved or not, automatic or manual - with the real, already-computed
/// scores/gates/outcome for each. Mirrors the <see cref="AdminUserController"/>/<see cref="AdminSettingsController"/>
/// pattern: <c>[Authorize(Roles = "Admin")]</c> at the controller level, a thin pass-through to FinlyticEngine
/// over MQTT RPC, and <see cref="ControllerBase.Problem"/> (never an anonymous object) on failure.
/// </summary>
[ApiController]
[Route("api/v1/admin/evaluations")]
[Authorize(Roles = "Admin")]
[EnableCors("AllowAll")]
public class AdminEvaluationHistoryController : ControllerBase
{
private readonly WebMqttClient _mqttClient;
private readonly ILogger<AdminEvaluationHistoryController> _logger;
public AdminEvaluationHistoryController(WebMqttClient mqttClient, ILogger<AdminEvaluationHistoryController> logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
/// <summary>
/// Returns a filtered, paginated page of evaluation-history rows plus a summary of the same (unpaginated)
/// filtered set - see <see cref="GetEvaluationHistoryRequest"/>/<see cref="GetEvaluationHistoryResponse"/>
/// for the exact filter and aggregation semantics. All query parameters are optional; omitting a filter
/// means "do not restrict on this field".
/// </summary>
/// <param name="fromUtc">Inclusive lower bound on <c>EvaluatedAtUtc</c>.</param>
/// <param name="toUtc">Inclusive upper bound on <c>EvaluatedAtUtc</c>.</param>
/// <param name="outcome">Restricts results to a single <see cref="OutcomeReason"/>.</param>
/// <param name="triggerSource">Restricts results to a single <see cref="TriggerSource"/>.</param>
/// <param name="search">Case-sensitive substring search against both ISIN and Symbol.</param>
/// <param name="page">1-based page number (defaults to 1; values below 1 are treated as 1 server-side).</param>
/// <param name="pageSize">
/// Requested page size (defaults to 50; server-side clamped to at most 200 by FinlyticEngine to prevent an
/// unbounded response).
/// </param>
[HttpGet]
public async Task<IActionResult> GetEvaluationHistory(
[FromQuery] DateTime? fromUtc = null,
[FromQuery] DateTime? toUtc = null,
[FromQuery] OutcomeReason? outcome = null,
[FromQuery] TriggerSource? triggerSource = null,
[FromQuery] string? search = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
{
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
var request = new GetEvaluationHistoryRequest(
FromUtc: fromUtc,
ToUtc: toUtc,
OutcomeFilter: outcome,
TriggerSourceFilter: triggerSource,
IsinOrSymbolSearch: search,
Page: page,
PageSize: pageSize
);
var result = await _mqttClient.SendRpcRequestAsync<GetEvaluationHistoryResponse, GetEvaluationHistoryRequest>(
MqttTopics.Channels.EngineGetEvaluationHistory, request, TimeSpan.FromSeconds(10));
if (result == null)
{
// Null means the RPC call itself got no response (transport failure) - not a legitimate "zero
// results" outcome, which is always a populated response with an empty Entries list.
return Problem(title: "FinlyticEngine did not respond to the evaluation history request.", statusCode: StatusCodes.Status502BadGateway);
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "[AdminEvaluationHistory] Failed to fetch evaluation history via MQTT RPC.");
return Problem(title: "Internal server error fetching evaluation history.", statusCode: StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Returns FinlyticTechnicals' currently monitored scan universe ("watchlist") - the assets the background
/// scanner is actively evaluating every cycle, independent of whether any of them have cleared the
/// engine's opportunity-poller score threshold yet.
/// </summary>
[HttpGet("watchlist")]
public async Task<IActionResult> GetWatchlist()
{
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticTechnicals is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
var result = await _mqttClient.SendRpcRequestAsync<List<WatchlistEntryDto>, object>(
MqttTopics.Channels.TaGetWatchlist, new object(), TimeSpan.FromSeconds(10));
if (result == null)
{
return Problem(title: "FinlyticTechnicals did not respond to the watchlist request.", statusCode: StatusCodes.Status502BadGateway);
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "[AdminEvaluationHistory] Failed to fetch watchlist via MQTT RPC.");
return Problem(title: "Internal server error fetching the watchlist.", statusCode: StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Returns the last <paramref name="limit"/> technical-analysis setups computed for <paramref name="isin"/>
/// (most recent first), so the admin UI can show whether its quality score is trending up or down across
/// recent scan cycles - including setups too weak to ever have reached the engine.
/// </summary>
[HttpGet("watchlist/{isin}/history")]
public async Task<IActionResult> GetWatchlistEntryHistory([FromRoute] string isin, [FromQuery] int limit = 8)
{
if (string.IsNullOrWhiteSpace(isin))
{
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
}
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticTechnicals is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
var result = await _mqttClient.SendRpcRequestAsync<List<StrategyResultDto>, GetRecentSetupHistoryRequest>(
MqttTopics.Channels.TaGetRecentSetupHistory,
new GetRecentSetupHistoryRequest(isin.Trim().ToUpperInvariant(), limit),
TimeSpan.FromSeconds(10));
if (result == null)
{
return Problem(title: "FinlyticTechnicals did not respond to the setup history request.", statusCode: StatusCodes.Status502BadGateway);
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "[AdminEvaluationHistory] Failed to fetch setup history for ISIN '{Isin}' via MQTT RPC.", isin);
return Problem(title: "Internal server error fetching the setup history.", statusCode: StatusCodes.Status500InternalServerError);
}
}
}
@@ -4,12 +4,15 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticBackend.Settings;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Services;
using FinlyticCore.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -37,6 +40,15 @@ public record ServiceConfigItemResponseDto(
[property: JsonPropertyName("updatedAt")] DateTime UpdatedAt
);
/// <summary>
/// Result DTO for a service settings update request, reporting whether the RPC broadcast to the
/// targeted microservice actually succeeded (AOT-compliant; replaces an anonymous response object).
/// </summary>
public record ServiceSettingsUpdateResultDto(
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("mqttDispatched")] bool MqttDispatched
);
/// <summary>
/// DTO representing the operational health status of a microservice (AOT-compliant).
/// </summary>
@@ -63,24 +75,55 @@ public class AdminSettingsController : ControllerBase
{
["FinlyticFundamentals"] = "fundamentals",
["FinlyticNews"] = "news",
["FinlyticTechnicals"] = "ta",
["FinlyticTechnicalAnalysis"] = "ta",
["FinlyticSentiment"] = "sentiment",
["FinlyticAnalyzer"] = "analyzer",
["FinlyticTrades"] = "trades",
["FinlyticAssets"] = "assets",
["FinlyticBot"] = "bot"
["FinlyticEngine"] = "engine",
["FinlyticAnalyzer"] = "engine",
["FinlyticTrades"] = "engine",
["FinlyticBot"] = "bot",
// FinlyticSimulation was previously missing from this map entirely, so its settings
// (SimulationSettingKeys: slippage/fee defaults, matrix-recompute schedule, etc.) never showed up
// in the admin UI's settings screen even though the sim_settings_GetAll/Update RPC channels exist.
["FinlyticSimulation"] = "sim"
};
/// <summary>
/// FinlyticBackend is the RPC *caller* for every entry in <see cref="ServiceRpcPrefixes"/> above, not a
/// callee - it has no MQTT-served "backend_settings_GetAll" channel to ask, because it would just be
/// asking itself over the network for no reason. Its own settings (<see cref="BackendSettingKeys"/>) are
/// instead read directly, in-process, via <see cref="_settingsService"/> - see the special-casing in
/// <see cref="GetAllSettings"/>/<see cref="GetServiceSettings"/>/<see cref="UpdateServiceSettings"/>.
/// </summary>
private const string BackendServiceName = "FinlyticBackend";
private static readonly ConcurrentDictionary<string, List<ServiceConfigItem>> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
private readonly ISettingsService _settingsService;
public AdminSettingsController(
WebMqttClient mqttClient,
ISettingsService settingsService,
ILogger<AdminSettingsController> logger)
{
_mqttClient = mqttClient;
_settingsService = settingsService;
_logger = logger;
}
private async Task<List<ServiceConfigItemResponseDto>> GetBackendOwnSettingsAsync()
{
var settings = await _settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BackendSettingKeys) });
return settings.Select(d => new ServiceConfigItemResponseDto(
Key: d.Key,
Value: d.Value?.ToString() ?? "",
DataType: d.Type,
Description: d.Description,
UpdatedAt: d.UpdatedAt ?? DateTime.UtcNow
)).ToList();
}
/// <summary>
/// Retrieves recent buffered in-memory logs for a specific service.
/// </summary>
@@ -144,6 +187,10 @@ public class AdminSettingsController : ControllerBase
)).ToList()
);
// FinlyticBackend's own settings never go through the MQTT RPC loop above (see BackendServiceName's
// doc comment) - read directly, in-process.
grouped[BackendServiceName] = await GetBackendOwnSettingsAsync();
return Ok(grouped);
}
@@ -155,13 +202,13 @@ public class AdminSettingsController : ControllerBase
{
var servicesToCheck = new (string Name, string Channel, string Type, string Db)[]
{
("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
("FinlyticTechnicalAnalysis", "health_Ping/FinlyticTechnicalAnalysis", "Technical Indicators (EMA/RSI)", "PostgreSQL ta"),
("FinlyticSentiment", "health_Ping/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
("FinlyticAnalyzer", "health_Ping/FinlyticAnalyzer", "Multi-Layer Signal Engine", "PostgreSQL analyzer"),
("FinlyticTrades", "health_Ping/FinlyticTrades", "Trade Lifecycle Manager", "PostgreSQL trades"),
("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
("FinlyticAssets", $"{MqttTopics.Channels.HealthPing}/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
("FinlyticNews", $"{MqttTopics.Channels.HealthPing}/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
("FinlyticTechnicals", $"{MqttTopics.Channels.HealthPing}/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "PostgreSQL ta"),
("FinlyticSentiment", $"{MqttTopics.Channels.HealthPing}/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
("FinlyticFundamentals", $"{MqttTopics.Channels.HealthPing}/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
("FinlyticEngine", $"{MqttTopics.Channels.HealthPing}/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
("FinlyticBot", $"{MqttTopics.Channels.HealthPing}/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
};
var results = new List<ServiceHealthStatusDto>
@@ -231,6 +278,11 @@ public class AdminSettingsController : ControllerBase
[HttpGet("{serviceName}")]
public async Task<IActionResult> GetServiceSettings(string serviceName)
{
if (string.Equals(serviceName, BackendServiceName, StringComparison.OrdinalIgnoreCase))
{
return Ok(await GetBackendOwnSettingsAsync());
}
if (ServiceRpcPrefixes.TryGetValue(serviceName, out var prefix) && _mqttClient.IsConnected)
{
try
@@ -281,7 +333,17 @@ public class AdminSettingsController : ControllerBase
{
if (updatedValues == null || !updatedValues.Any())
{
return BadRequest(new { message = "No settings provided for update." });
return Problem(title: "No settings provided for update.", statusCode: StatusCodes.Status400BadRequest);
}
if (string.Equals(serviceName, BackendServiceName, StringComparison.OrdinalIgnoreCase))
{
// No MQTT round trip needed - FinlyticBackend updates its own dynamic settings directly, in-process.
await _settingsService.UpdateSettingsAsync(updatedValues);
return Ok(new ServiceSettingsUpdateResultDto(
Message: $"Settings successfully updated for {BackendServiceName}.",
MqttDispatched: false
));
}
_logger.LogInformation("[AdminSettings] Transmitting {Count} config settings to microservice '{ServiceName}' via MQTT", updatedValues.Count, serviceName);
@@ -307,16 +369,11 @@ public class AdminSettingsController : ControllerBase
)).ToList();
}
string topic = $"services/config/updated/{serviceName}";
var payload = new ServiceConfigUpdatePayload(
ServiceName: serviceName,
Timestamp: DateTime.UtcNow,
Settings: updatedValues.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString() ?? "")
);
await _mqttClient.PublishAsync(topic, payload);
mqttPublished = true;
_logger.LogInformation("[AdminSettings] Broadcasted config update event to MQTT topic '{Topic}' for microservice persistence.", topic);
// Settings propagation happens entirely over the {prefix}_settings_Update RPC call above.
// There used to be an additional fire-and-forget MQTT publish to a per-service config-updated
// topic here, but no service in the fleet ever subscribed to it (dead legacy code predating the
// RPC mechanism) - removed rather than kept as a no-op broadcast.
mqttPublished = updated != null && updated.Count > 0;
}
}
catch (Exception ex)
@@ -324,10 +381,9 @@ public class AdminSettingsController : ControllerBase
_logger.LogError(ex, "[AdminSettings] Failed to publish MQTT config update event for service '{ServiceName}'", serviceName);
}
return Ok(new
{
message = $"Settings successfully transmitted to microservice {serviceName}.",
mqttDispatched = mqttPublished
});
return Ok(new ServiceSettingsUpdateResultDto(
Message: $"Settings successfully transmitted to microservice {serviceName}.",
MqttDispatched: mqttPublished
));
}
}
+78 -110
View File
@@ -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; }
}
}
@@ -7,7 +7,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticAssets.Util;
using FinlyticBackend.Database;
using FinlyticBackend.Util;
@@ -268,6 +267,42 @@ public class AssetsController : ControllerBase
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
}
/// <summary>
/// Liest den aktuellen Live-Kurs eines Assets oder Derivats via TR MQTT RPC.
/// </summary>
[HttpGet("{isin}/live")]
public async Task<IActionResult> GetLivePrice([FromRoute] string isin)
{
string normalizedSymbol = isin.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(normalizedSymbol))
{
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
}
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
"tr_GetLivePrice",
new IsinRequest(normalizedSymbol),
TimeSpan.FromSeconds(5)
);
if (rpcResult != null && rpcResult.CurrentPrice > 0m)
{
return Ok(rpcResult);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RPC tr_GetLivePrice für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
}
return NotFound(new { message = $"Kein Live-Kurs für Asset '{normalizedSymbol}' verfügbar." });
}
/// <summary>
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
/// </summary>
@@ -304,7 +339,7 @@ public class AssetsController : ControllerBase
ForceRefresh: forceRefresh
);
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDerivativesRequest>(
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<DerivativeDto>, GetDerivativesRequest>(
"assets_GetDerivatives",
req,
TimeSpan.FromSeconds(30)
@@ -312,7 +347,7 @@ public class AssetsController : ControllerBase
if (rpcResult != null)
{
var derivatives = rpcResult.OfType<DerivativeDto>().ToList();
var derivatives = rpcResult;
if (minLeverage.HasValue)
{
@@ -365,6 +400,10 @@ public class AssetsController : ControllerBase
/// <summary>
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
/// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7), not an
/// oversight: logos are static, non-user-specific assets (looked up only by ISIN), and generic image
/// loaders/`&lt;img&gt;` tags do not attach an <c>Authorization</c> header by default. Keeping this endpoint
/// anonymous lets every image loader render it without special-casing headers.
/// </summary>
[HttpGet("/api/v1/logo/{isin}")]
[AllowAnonymous]
+27 -11
View File
@@ -21,17 +21,15 @@ public record UserProfileResponseDto(
[property: JsonPropertyName("role")] string Role
);
/// <summary>
/// Request payload for changing a user's initial (admin-issued) password. The target user is derived
/// from the caller's own JWT identity claim (see <see cref="AuthController.ChangeInitialPassword"/>),
/// not from this payload, so it deliberately carries no user identifier.
/// </summary>
public record ChangeInitialPasswordDto(
[property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("newPassword")] string NewPassword
);
public class ForgotPasswordRequestDto
{
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
}
[ApiController]
[Route("api/v1")]
public class AuthController : ControllerBase
@@ -45,7 +43,13 @@ public class AuthController : ControllerBase
/// <summary>
/// Authenticates a user and returns a JWT token.
/// Deliberately the single anonymous authentication entry point of the Gateway (Rules.md §7): a client
/// has no JWT to present before it has logged in, so there is no way to require authentication here.
/// Every other route in the Gateway requires authentication except three other sanctioned exceptions:
/// the Docker healthcheck (<c>GET /health</c>), the static asset-logo endpoint (<c>GET /api/v1/logo/{isin}</c>,
/// which image loaders cannot attach a bearer token to), and the Flutter Web SPA fallback file.
/// </summary>
[AllowAnonymous]
[HttpPost("auth/login")]
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
{
@@ -64,17 +68,29 @@ public class AuthController : ControllerBase
}
/// <summary>
/// Changes the initial password required by an admin reset or creation.
/// Changes the initial password required after an admin-driven account creation or password reset.
/// Requires authentication (Rules.md §7): a caller always already holds a valid JWT at this point,
/// because <see cref="Login"/> issues one immediately, carrying <c>RequiresPasswordChange</c> in the
/// response body, before the client ever calls this endpoint. The target user is derived from the
/// caller's own token claim rather than accepted as a request parameter, so a caller can never
/// change another account's initial password by supplying an arbitrary user identifier.
/// </summary>
[Authorize]
[HttpPost("auth/change-initial-password")]
public async Task<IActionResult> ChangeInitialPassword([FromBody] ChangeInitialPasswordDto request, CancellationToken cancellationToken)
{
if (request.UserId == Guid.Empty || string.IsNullOrWhiteSpace(request.NewPassword))
if (string.IsNullOrWhiteSpace(request?.NewPassword))
{
return BadRequest(new { error = "UserId and NewPassword are required." });
return BadRequest(new { error = "NewPassword is required." });
}
bool changed = await _userService.ChangeInitialPasswordAsync(request.UserId, request.NewPassword, cancellationToken);
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value;
if (!Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized(new { error = "Invalid User Token claim." });
}
bool changed = await _userService.ChangeInitialPasswordAsync(userId, request.NewPassword, cancellationToken);
if (!changed)
{
return BadRequest(new { error = "Password change failed. User not found or password change not required." });
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Util;
using FinlyticBackend.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/bot")]
public class BotController : ControllerBase
{
private readonly BackendMqttBridge _mqttBridge;
private readonly ILogger<BotController> _logger;
public BotController(BackendMqttBridge mqttBridge, ILogger<BotController> logger)
{
_mqttBridge = mqttBridge;
_logger = logger;
}
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
try
{
var status = await _mqttBridge.SendRpcRequestAsync<BotStatusDto, object>(
"bot_GetStatus",
new object(),
TimeSpan.FromSeconds(5)
);
return Ok(status ?? new BotStatusDto(false, false, 0, 5, 1.0m, 75, "Unknown"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting bot status");
return StatusCode(500, new { Message = ex.Message });
}
}
[HttpGet("positions/active")]
public async Task<IActionResult> GetActivePositions()
{
try
{
var positions = await _mqttBridge.SendRpcRequestAsync<List<BotTradeOrderDto>, object>(
"bot_GetPositions",
new object(),
TimeSpan.FromSeconds(5)
);
return Ok(positions ?? new List<BotTradeOrderDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting active bot positions");
return StatusCode(500, new { Message = ex.Message });
}
}
[HttpGet("portfolio/summary")]
public async Task<IActionResult> GetPortfolioSummary()
{
try
{
var summary = await _mqttBridge.SendRpcRequestAsync<AccountSummaryDto, object>(
"bot_GetSummary",
new object(),
TimeSpan.FromSeconds(5)
);
if (summary == null)
{
// No fabricated account numbers (Rules.md §4): if FinlyticBot didn't answer the RPC in time,
// report that honestly instead of inventing a fake portfolio.
return Problem(title: "FinlyticBot is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
return Ok(summary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting bot portfolio summary");
return StatusCode(500, new { Message = ex.Message });
}
}
[HttpPost("orders/execute")]
public async Task<IActionResult> ExecuteProposal([FromBody] ExecuteProposalRequest request)
{
if (request == null || request.ProposalId == Guid.Empty)
{
return BadRequest(new { Message = "Valid ProposalId is required." });
}
try
{
var order = await _mqttBridge.SendRpcRequestAsync<BotTradeOrderDto, ExecuteProposalRequest>(
"bot_ExecuteProposal",
request,
TimeSpan.FromSeconds(10)
);
if (order == null)
{
return StatusCode(422, new { Message = "Proposal could not be executed (Risk Gate rejection or not found)." });
}
return Ok(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing bot proposal {Id}", request.ProposalId);
return StatusCode(500, new { Message = ex.Message });
}
}
/// <summary>
/// Emergency-closes every open FinlyticBot paper-trading position. Served by FinlyticBot's
/// <c>bot_PanicClose</c> RPC handler (<c>FinlyticBot.Util.BotMqttClient.HandlePanicCloseRpcAsync</c>),
/// which closes synthetic-ledger positions unconditionally but only closes Alpaca positions the broker
/// has actually confirmed liquidating — anything it could not confirm is left open and reported via
/// <see cref="PanicCloseResultDto.SkippedCount"/> rather than silently counted as closed (Rules.md §4).
/// </summary>
[HttpPost("orders/panic-close")]
public async Task<IActionResult> PanicCloseAllPositions()
{
try
{
var result = await _mqttBridge.SendRpcRequestAsync<PanicCloseResultDto, object>(
MqttTopics.Channels.BotPanicClose,
new object(),
TimeSpan.FromSeconds(15)
);
if (result == null)
{
// FinlyticBot did not answer in time: do NOT report a fabricated "0 closed" success for an
// emergency action — a false-positive panic-close result would be the worst possible outcome
// (Rules.md §4). The caller must know this attempt did not go through at all.
return Problem(
title: "FinlyticBot is currently unreachable. Panic close could NOT be confirmed - positions may still be open.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering panic close");
return StatusCode(500, new { Message = ex.Message });
}
}
/// <summary>
/// Updates FinlyticBot's dynamic settings. Routed through the same generic
/// <c>Dictionary&lt;string, object?&gt;</c> -&gt; <c>{prefix}_settings_Update</c> -&gt;
/// <c>List&lt;DynamicSettingDto&gt;</c> contract every other microservice uses (see
/// <c>AdminSettingsController.UpdateServiceSettings</c>) rather than a bespoke, nonexistent
/// "bot_UpdateSettings" channel with a mismatched <see cref="BotStatusDto"/> contract. FinlyticBot is a
/// single shared instance with no per-tenant settings, so the generic mechanism applies directly.
/// </summary>
[HttpPost("settings/update")]
public async Task<IActionResult> UpdateBotSettings([FromBody] UpdateBotSettingsRequest request)
{
if (request == null)
{
return BadRequest(new { Message = "Settings payload is required." });
}
// Raw setting-key strings are used here (rather than a shared constants type) because
// FinlyticBackend has no project reference to FinlyticBot (by design - the backend is a thin MQTT
// aggregation bridge, not a consumer of worker-service internals). The canonical source of truth for
// these keys is FinlyticBot/Settings/BotSettingKeys.cs; this mirrors the identical precedent already
// used in the opposite direction in FinlyticBot.Util.BotMqttClient.HandleGetStatusRpcAsync, which reads
// FinlyticEngine's "Engine.MinCompositeScore" the same way for the same reason.
var updates = new Dictionary<string, object?>();
if (request.AutoExecutionEnabled.HasValue)
{
updates["Bot.EnableAutoExecution"] = request.AutoExecutionEnabled.Value;
}
if (request.MaxPositions.HasValue)
{
updates["Bot.MaxConcurrentPositions"] = request.MaxPositions.Value;
}
if (request.RiskPerTradePercent.HasValue)
{
updates["Bot.RiskPerTradePercent"] = request.RiskPerTradePercent.Value;
}
// request.MinCompositeScore is deliberately NOT forwarded: that value is owned by FinlyticEngine
// (EngineSettingKeys.MinCompositeScore == "Engine.MinCompositeScore"), not by FinlyticBot.
// FinlyticBot's generic settings_Update handler only persists keys registered under BotSettingKeys
// (see BotMqttClient.HandleSettingsGetAllRpcAsync/HandleSettingsUpdateRpcAsync), so writing an
// Engine-owned key through the Bot's settings channel would either be silently dropped or written to
// a key FinlyticBot itself never reads back — either way it would misrepresent to the caller that the
// score was updated. The request DTO field is left in place (not this fix's concern to remove) for a
// future change that routes it to FinlyticEngine's own settings channel instead.
if (request.MinCompositeScore.HasValue)
{
_logger.LogWarning(
"Ignoring MinCompositeScore={Score} in bot settings update request: this value is owned by FinlyticEngine, not FinlyticBot, and cannot be set through this endpoint.",
request.MinCompositeScore.Value);
}
if (updates.Count == 0)
{
return BadRequest(new { Message = "At least one settable field (AutoExecutionEnabled, MaxPositions, RiskPerTradePercent) must be provided." });
}
try
{
var updatedSettings = await _mqttBridge.SendRpcRequestAsync<List<DynamicSettingDto>, Dictionary<string, object?>>(
MqttTopics.Channels.BotSettingsUpdate,
updates,
TimeSpan.FromSeconds(5)
);
if (updatedSettings == null)
{
// No fabricated success (Rules.md §4): if FinlyticBot didn't answer the RPC in time, report
// that honestly instead of inventing a BotStatusDto that implies the settings were applied.
return Problem(title: "FinlyticBot is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
return Ok(updatedSettings);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating bot settings");
return StatusCode(500, new { Message = ex.Message });
}
}
}
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticCore.Models.Assets;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
@@ -90,7 +90,7 @@ public class CalendarController : ControllerBase
EventType: eventType,
EventDate: eventDate,
Date: eventDate.ToString("o"),
Isin: e.Isin,
Isin: eventIsin,
Ticker: ticker,
Description: $"{eventType} - {companyName}",
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace FinlyticBackend.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
[Authorize(Roles = "Admin")]
public class DashboardController : ControllerBase
{
/// <summary>
/// Retrieves global statistics aggregated from all microservices.
/// Currently returns mocked data for UI development.
/// </summary>
[HttpGet("stats")]
public IActionResult GetGlobalStats()
{
return Ok(new
{
totalAssetsLoaded = 12450,
totalNewsArticles = 8340,
activeTrades = 12,
systemUptimeHours = 342,
sentimentAnalysesCompleted = 45000,
technicalAnalysesCompleted = 120000
});
}
}
@@ -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." });
}
}
}
@@ -64,7 +64,7 @@ public class NewsController : ControllerBase
HasSentiment: hasSentiment
);
_logger.LogInformation("[payload] " + payload.Date.ToString());
_logger.LogInformation("[NewsController] Fetching news. Date filter: {Date}", payload.Date?.ToString("o") ?? "None");
var articles = await _mqttClient.SendRpcRequestAsync<List<NewsArticleDto>, DailyNewsRequest>(
"news_Get",
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Simulation;
using FinlyticBackend.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/simulation")]
public class SimulationController : ControllerBase
{
private readonly BackendMqttBridge _mqttBridge;
private readonly ILogger<SimulationController> _logger;
public SimulationController(BackendMqttBridge mqttBridge, ILogger<SimulationController> logger)
{
_mqttBridge = mqttBridge;
_logger = logger;
}
[HttpPost("run")]
public async Task<IActionResult> RunBacktest([FromBody] BacktestRequestDto request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Isin) || string.IsNullOrWhiteSpace(request.StrategyKey))
{
return BadRequest(new { Message = "ISIN and StrategyKey are required for backtesting." });
}
try
{
_logger.LogInformation("Starting backtest via REST for {Isin} ({StrategyKey}) on {Timeframe}", request.Isin, request.StrategyKey, request.Timeframe);
var report = await _mqttBridge.SendRpcRequestAsync<BacktestReportDto, BacktestRequestDto>(
"sim_RunBacktest",
request,
TimeSpan.FromSeconds(25)
);
if (report == null)
{
return StatusCode(504, new { Message = "Simulation service timed out or did not return a report." });
}
return Ok(report);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running backtest for {Isin}", request.Isin);
return StatusCode(500, new { Message = ex.Message });
}
}
[HttpGet("matrix/{isin}")]
public async Task<IActionResult> GetStrategyMatrix(string isin)
{
if (string.IsNullOrWhiteSpace(isin))
{
return BadRequest(new { Message = "ISIN is required." });
}
try
{
var matrix = await _mqttBridge.SendRpcRequestAsync<List<StrategyAssetReliabilityDto>, IsinRequest>(
"sim_GetMatrixForAsset",
new IsinRequest(isin, null, false),
TimeSpan.FromSeconds(5)
);
return Ok(matrix ?? new List<StrategyAssetReliabilityDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching strategy matrix for {Isin}", isin);
return StatusCode(500, new { Message = ex.Message });
}
}
/// <summary>
/// Lightweight history of past backtest runs for an ISIN, optionally narrowed to one strategy - every run
/// is already persisted server-side (<c>sim_RunBacktest</c>) but was previously only reachable indirectly
/// via the reliability matrix, never queryable as a history in its own right.
/// </summary>
[HttpGet("history/{isin}")]
public async Task<IActionResult> GetBacktestHistory(string isin, [FromQuery] string? strategyKey = null, [FromQuery] int limit = 20)
{
if (string.IsNullOrWhiteSpace(isin))
{
return BadRequest(new { Message = "ISIN is required." });
}
try
{
var history = await _mqttBridge.SendRpcRequestAsync<List<BacktestHistoryEntryDto>, GetBacktestHistoryRequest>(
"sim_GetBacktestHistory",
new GetBacktestHistoryRequest(isin, strategyKey, limit),
TimeSpan.FromSeconds(5)
);
return Ok(history ?? new List<BacktestHistoryEntryDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching backtest history for {Isin}", isin);
return StatusCode(500, new { Message = ex.Message });
}
}
/// <summary>Full report (trades + equity curve) for one specific past backtest run, for drilling into a history entry.</summary>
[HttpGet("history/run/{runId:guid}")]
public async Task<IActionResult> GetBacktestRunDetail(Guid runId)
{
try
{
var report = await _mqttBridge.SendRpcRequestAsync<BacktestReportDto, GetBacktestRunDetailRequest>(
"sim_GetBacktestRunDetail",
new GetBacktestRunDetailRequest(runId),
TimeSpan.FromSeconds(5)
);
if (report == null)
{
return NotFound(new { Message = $"No backtest run found for RunId {runId}." });
}
return Ok(report);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching backtest run detail for {RunId}", runId);
return StatusCode(500, new { Message = ex.Message });
}
}
/// <summary>Saved indicator-parameter profile for one (Isin, StrategyKey) pair, or 404 if none was ever saved.</summary>
[HttpGet("parameters/{isin}/{strategyKey}")]
public async Task<IActionResult> GetStrategyParameters(string isin, string strategyKey)
{
if (string.IsNullOrWhiteSpace(isin) || string.IsNullOrWhiteSpace(strategyKey))
{
return BadRequest(new { Message = "ISIN and StrategyKey are required." });
}
try
{
var profile = await _mqttBridge.SendRpcRequestAsync<StrategyParameterProfileDto, GetStrategyParametersRequest>(
"sim_GetStrategyParameters",
new GetStrategyParametersRequest(isin, strategyKey),
TimeSpan.FromSeconds(5)
);
if (profile == null)
{
return NotFound(new { Message = $"No saved parameter profile for {isin}/{strategyKey}." });
}
return Ok(profile);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching strategy parameters for {Isin}/{StrategyKey}", isin, strategyKey);
return StatusCode(500, new { Message = ex.Message });
}
}
/// <summary>Saves/updates a per-asset/per-strategy indicator-parameter profile for future backtests to reuse.</summary>
[HttpPost("parameters")]
public async Task<IActionResult> SaveStrategyParameters([FromBody] SaveStrategyParametersRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Isin) || string.IsNullOrWhiteSpace(request.StrategyKey))
{
return BadRequest(new { Message = "ISIN and StrategyKey are required." });
}
try
{
var profile = await _mqttBridge.SendRpcRequestAsync<StrategyParameterProfileDto, SaveStrategyParametersRequest>(
"sim_SaveStrategyParameters",
request,
TimeSpan.FromSeconds(5)
);
if (profile == null)
{
return StatusCode(504, new { Message = "Simulation service timed out or did not confirm the save." });
}
return Ok(profile);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving strategy parameters for {Isin}/{StrategyKey}", request.Isin, request.StrategyKey);
return StatusCode(500, new { Message = ex.Message });
}
}
}
@@ -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" });
}
}
}
}