feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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/`<img>` 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]
|
||||
|
||||
@@ -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<string, object?></c> -> <c>{prefix}_settings_Update</c> ->
|
||||
/// <c>List<DynamicSettingDto></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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user