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." });
}
// 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));
var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
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, "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")
?? 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" });
}
}
}
+31 -1
View File
@@ -1,9 +1,16 @@
using FinlyticBackend.Entities;
using FinlyticCore.Database;
using FinlyticCore.Entities.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticBackend.Database;
public class BackendDbContext : DbContext
/// <summary>
/// FinlyticBackend previously implemented no <see cref="ISettingsDbContext"/> at all, unlike every other
/// service - which is why it never had dynamic settings (logging channels etc.) to show in the admin UI.
/// </summary>
public class BackendDbContext : DbContext, ISettingsDbContext
{
public BackendDbContext(DbContextOptions<BackendDbContext> options) : base(options) { }
@@ -11,11 +18,18 @@ public class BackendDbContext : DbContext
public DbSet<UserDeviceTokenEntity> UserDeviceTokens => Set<UserDeviceTokenEntity>();
public DbSet<UserFavoriteAssetEntity> UserFavoriteAssets => Set<UserFavoriteAssetEntity>();
public DbSet<ServiceConfigurationEntity> ServiceConfigurations => Set<ServiceConfigurationEntity>();
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SettingEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique();
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("users");
@@ -73,3 +87,19 @@ public class BackendDbContext : DbContext
});
}
}
/// <summary>
/// Lets `dotnet ef migrations add` construct a <see cref="BackendDbContext"/> without booting the full
/// application (which fails fast at startup if JWT_SECRET_KEY/ADMIN_DEFAULT_PASSWORD aren't set - see
/// Program.cs) - the same pattern every other service's DbContext already uses
/// (e.g. <c>TechnicalAnalysisDbContextFactory</c>).
/// </summary>
public class BackendDbContextFactory : IDesignTimeDbContextFactory<BackendDbContext>
{
public BackendDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<BackendDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_backend;Username=postgres;Password=postgres");
return new BackendDbContext(optionsBuilder.Options);
}
}
+9 -3
View File
@@ -1,16 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
COPY ["FinlyticBackend/FinlyticBackend.csproj", "FinlyticBackend/"]
RUN dotnet restore "FinlyticBackend/FinlyticBackend.csproj"
COPY . .
WORKDIR "/src/FinlyticBackend"
RUN dotnet build "FinlyticBackend.csproj" -c Release -o /app/build
RUN dotnet build "FinlyticBackend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
RUN dotnet publish "FinlyticBackend.csproj" -c Release -o /app/publish /p:UseAppHost=false
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "FinlyticBackend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FinlyticBackend.dll"]
+2
View File
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -8,6 +9,7 @@ namespace FinlyticBackend.Hubs;
/// <summary>
/// SignalR Hub that streams live log messages from microservices to connected web UI clients.
/// </summary>
[Authorize]
public class LogStreamHub : Hub
{
private readonly ILogger<LogStreamHub> _logger;
+2
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News;
@@ -7,6 +8,7 @@ namespace FinlyticBackend.Hubs;
/// <summary>
/// SignalR Hub for real-time news delivery to connected clients.
/// </summary>
[Authorize]
public class NewsHub : Hub
{
// Clients can call this to join specific symbol groups if needed later
+2
View File
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -8,6 +9,7 @@ namespace FinlyticBackend.Hubs;
/// <summary>
/// SignalR Hub broadcasting real-time system diagnostics & microservice health updates to connected clients.
/// </summary>
[Authorize]
public class SystemHealthHub : Hub
{
private readonly ILogger<SystemHealthHub> _logger;
-14
View File
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.SignalR;
namespace FinlyticBackend.Hubs;
/// <summary>
/// SignalR Hub broadcasting real-time trade signals, position updates, and closed trade events.
/// </summary>
public class TradeHub : Hub
{
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
}
}
-33
View File
@@ -1,33 +0,0 @@
using System;
using System.Threading.Tasks;
using FinlyticCore.Models.Auth;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Hubs;
/// <summary>
/// Real-time SignalR WebSocket & Server-Sent Events (SSE) Hub streaming live trade proposals & updates to Web and Mobile clients.
/// </summary>
public class TradeRealtimeHub : Hub<ITradeClient>
{
private readonly ILogger<TradeRealtimeHub> _logger;
public TradeRealtimeHub(ILogger<TradeRealtimeHub> logger)
{
_logger = logger;
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation("Real-time SignalR Client connected: ConnectionId={ConnectionId}, User={User}",
Context.ConnectionId, Context.User?.Identity?.Name ?? "Anonymous");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Real-time SignalR Client disconnected: ConnectionId={ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Trading;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace FinlyticBackend.Hubs;
public interface ITradeStreamClient
{
Task ReceiveTradeProposal(TradeProposalDto proposal);
Task ReceiveTradeUpdate(ActiveTradeDto trade);
Task ReceiveBotPositionUpdate(BotTradeOrderDto botOrder);
Task ReceivePortfolioSummary(AccountSummaryDto summary);
}
[Authorize]
public class TradeStreamHub : Hub<ITradeStreamClient>
{
public async Task JoinAssetGroup(string isin)
{
if (!string.IsNullOrWhiteSpace(isin))
{
await Groups.AddToGroupAsync(Context.ConnectionId, isin.Trim().ToUpperInvariant());
}
}
public async Task LeaveAssetGroup(string isin)
{
if (!string.IsNullOrWhiteSpace(isin))
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, isin.Trim().ToUpperInvariant());
}
}
}
@@ -0,0 +1,57 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Middleware;
/// <summary>
/// Global fallback exception handler for the Gateway (Rules.md §11). Converts any unhandled exception that
/// escapes a controller action, hub method, or other endpoint into a standardized RFC 7807 Problem Details
/// response instead of an ad-hoc anonymous error object or a leaked stack trace.
/// Registered via <c>AddExceptionHandler&lt;GlobalExceptionHandler&gt;()</c> and activated by
/// <c>app.UseExceptionHandler()</c> in <c>Program.cs</c>. This only intercepts exceptions thrown while a
/// request is being handled; it has no effect on the fail-fast startup checks in <c>Program.cs</c>
/// (missing/weak <c>JWT:SecretKey</c>, missing <c>ADMIN:DefaultPassword</c>), which throw before
/// <c>builder.Build()</c> - i.e. before this middleware pipeline exists - and are intentionally left
/// unhandled so the host refuses to start.
/// </summary>
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
/// <inheritdoc />
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
_logger.LogError(
exception,
"Unhandled exception while processing {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
var problemDetailsService = httpContext.RequestServices.GetRequiredService<IProblemDetailsService>();
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Detail = "The Gateway encountered an unexpected error while processing the request.",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
}
});
}
}
@@ -0,0 +1,270 @@
// <auto-generated />
using System;
using FinlyticBackend.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticBackend.Migrations
{
[DbContext(typeof(BackendDbContext))]
[Migration("20260822131602_AddDynamicSettings")]
partial class AddDynamicSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticBackend.Entities.ServiceConfigurationEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ConfigKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("config_key");
b.Property<string>("ConfigValue")
.IsRequired()
.HasColumnType("text")
.HasColumnName("config_value");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("data_type");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("description");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("ServiceName", "ConfigKey")
.IsUnique();
b.ToTable("service_configurations", (string)null);
});
modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("device_name");
b.Property<string>("FcmToken")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("fcm_token");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_used_at");
b.Property<DateTime>("RegisteredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("registered_at");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("FcmToken");
b.HasIndex("UserId");
b.ToTable("user_device_tokens", (string)null);
});
modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)")
.HasColumnName("email");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password_hash");
b.Property<bool>("RequiresPasswordChange")
.HasColumnType("boolean");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("ThemePreference")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("theme_preference");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Role");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("isin");
b.Property<string>("SelectedTicker")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("UserId", "Isin")
.IsUnique();
b.ToTable("user_favorite_assets", (string)null);
});
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
{
b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
.WithMany("DeviceTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
{
b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
{
b.Navigation("DeviceTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticBackend.Migrations
{
/// <inheritdoc />
public partial class AddDynamicSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DynamicSettings");
}
}
}
@@ -204,6 +204,37 @@ namespace FinlyticBackend.Migrations
b.ToTable("user_favorite_assets", (string)null);
});
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
{
b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+73 -11
View File
@@ -4,10 +4,15 @@ using System.Threading.Tasks;
using FinlyticBackend.Controllers;
using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
using FinlyticBackend.Middleware;
using FinlyticBackend.Services;
using FinlyticBackend.Util;
using FinlyticCore.Database;
using FinlyticCore.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -21,6 +26,12 @@ var builder = WebApplication.CreateBuilder(args);
// 1. Add Controllers
builder.Services.AddControllers();
// 1b. Standardized error responses (Rules.md §11): every unhandled exception becomes an RFC 7807
// Problem Details response via GlobalExceptionHandler instead of a raw stack trace or an anonymous
// error object hand-rolled per controller.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// 2. Define CORS Policy
builder.Services.AddCors(options =>
{
@@ -34,10 +45,28 @@ builder.Services.AddCors(options =>
});
// 3. Configure JWT Authentication
var secretKey = builder.Configuration["JWT:SecretKey"] ?? "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
// Fail-fast: a Gateway that falls back to a repo-public signing key is worse than one that refuses to
// start (Rules.md §12). JwtTokenService applies the same guard independently for defense in depth.
var secretKey = builder.Configuration["JWT:SecretKey"] ?? builder.Configuration["JWT__SecretKey"];
if (string.IsNullOrWhiteSpace(secretKey) || secretKey.Length < 32)
{
throw new InvalidOperationException(
"JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " +
"FinlyticBackend startet nicht ohne einen ausreichend starken, explizit konfigurierten Signing-Key.");
}
var issuer = builder.Configuration["JWT:Issuer"] ?? "FinlyticBackend";
var audience = builder.Configuration["JWT:Audience"] ?? "FinlyticClients";
// Fail-fast: the default Admin account must never be seeded with a hardcoded, repo-public password
// (Rules.md §12). UserService.SeedDefaultAdminAsync applies the same guard for defense in depth.
var adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? builder.Configuration["ADMIN__DefaultPassword"];
if (string.IsNullOrWhiteSpace(adminDefaultPassword))
{
throw new InvalidOperationException(
"ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
"FinlyticBackend startet nicht ohne ein explizit konfiguriertes Initial-Passwort für den Default-Admin-Account.");
}
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -80,6 +109,11 @@ builder.Services.AddSignalR();
// 4. Register DB Context & Services
builder.Services.AddDbContext<BackendDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<BackendDbContext>());
// 4b. Dynamic Settings & Channel-Based Logging (previously absent for FinlyticBackend - see BackendSettingKeys).
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
builder.Services.AddHttpClient<UserTradesController>();
builder.Services.AddHttpClient<UserFavoritesController>();
@@ -91,14 +125,34 @@ builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IFirebaseNotificationService, FirebaseNotificationService>();
builder.Services.AddSingleton<BackendMqttBridge>();
builder.Services.AddHostedService<BackendMqttBridge>(sp => sp.GetRequiredService<BackendMqttBridge>());
builder.Services.AddSingleton<WebMqttClient>();
builder.Services.AddHostedService<WebMqttClient>(sp => sp.GetRequiredService<WebMqttClient>());
builder.Services.AddHostedService<BackendMqttBridge>();
builder.Services.AddHostedService<SystemHealthBackgroundService>();
builder.Services.AddHostedService<FavoritesPriceBackgroundService>();
var app = builder.Build();
// 0. Global exception -> Problem Details handler (Rules.md §11). Placed first so it wraps every
// downstream stage (static files, routing, CORS, auth, endpoints): on an unhandled exception it re-invokes
// the pipeline starting immediately after itself for the generated error response, so UseCors below still
// runs and still attaches CORS headers to the error response. This does NOT change the CORS-critical
// ordering documented below - UseRouting/UseCors/UseAuthentication/UseAuthorization still execute in the
// same relative order for both the happy path and the error path.
app.UseExceptionHandler();
// 0b. Trust X-Forwarded-* from the nginx reverse proxy in front of this service, so Request.Scheme/
// Request.Host reflect the public domain (e.g. finlytic.kleidukos.me) instead of the raw loopback
// connection nginx makes to Kestrel (localhost:5000). Without this, any code building an absolute URL
// from Request.Host/Scheme silently produces http://localhost:5000/... links. KnownProxies/KnownNetworks
// are cleared because nginx runs as a sidecar on an address Kestrel can't predict (Docker bridge network).
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
KnownNetworks = { },
KnownProxies = { }
});
// ----------------------------------------------------------------------
// MIDDLEWARE PIPELINE ORDER IS CRITICAL FOR CORS
// ----------------------------------------------------------------------
@@ -155,12 +209,13 @@ using (var scope = app.Services.CreateScope())
try
{
var context = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
await context.Database.MigrateAsync();
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
await context.MigrateWithBootstrapAsync(connStr);
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
string adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? "AdminDefaultPassword2026!";
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
@@ -171,11 +226,7 @@ using (var scope = app.Services.CreateScope())
// Map Endpoints
app.MapControllers();
app.MapHub<TradeRealtimeHub>("/hubs/trades", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
app.MapHub<TradeHub>("/hubs/trade-updates", options =>
app.MapHub<TradeStreamHub>("/hubs/trade-stream", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
@@ -196,8 +247,19 @@ app.MapHub<LogStreamHub>("/hubs/logs", options =>
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow }));
// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): the Docker
// healthcheck defined in compose.yaml sends an unauthenticated `GET /health` against 127.0.0.1:8080 and
// only checks for a 200 status code. A healthcheck cannot carry a bearer token/secret, so requiring auth
// here would make every container report unhealthy. The response body is limited to {status, service,
// timestamp} — no business or user data — so the anonymous surface stays minimal.
app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow }))
.AllowAnonymous();
// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): this serves the
// compiled Flutter Web SPA shell (index.html) for any unmatched route. It must stay anonymous, or the
// browser could never load the login page in the first place - the SPA itself enforces auth client-side
// once loaded, and every actual data-bearing API route below requires a JWT.
app.MapFallbackToFile("index.html");
await app.RunAsync();
-37
View File
@@ -1,37 +0,0 @@
# Finlytic Backend Gateway
Finlytic Backend is the central ASP.NET Core Web API gateway and SignalR real-time broker for the Finlytic ecosystem. It serves external clients (`FinlyticApp`, `FinlyticWeb`) via REST endpoints and WebSockets, while interfacing internally with background microservices strictly via MQTT.
---
## Architecture & Responsibilities
1. **Single Public Entrypoint**:
- The **only** backend microservice hosting Kestrel HTTP REST and SignalR WebSocket endpoints.
2. **JWT Authentication & User Management**:
- Manages user registration, login, JWT token issuance (`IJwtTokenService`), and role-based access control (Admin, Premium, User).
3. **SignalR Real-Time Hubs**:
- **`NewsHub`** (`/hubs/news`): Broadcasts live news articles and sentiment classifications.
- **`TradeHub`** (`/hubs/trade-updates`): Broadcasts live trade proposals and position updates.
- **`TradeRealtimeHub`** (`/hubs/trades`): Legacy real-time trade signals hub.
4. **MQTT Bridge (`BackendMqttBridge` & `WebMqttClient`)**:
- Subscribes to internal MQTT topics (`finlytic/news/#`, `finlytic/sentiment/#`, `finlytic/trades/#`, `finlytic/fundamentals/#`, `finlytic/ta/#`).
- Forwards MQTT messages to connected SignalR WebSockets and executes MQTT RPC requests for REST controllers.
---
## Feature Status
### Implemented Features
- [x] JWT Authentication & User Persistence (`BackendDbContext`).
- [x] REST Controllers: `AuthController`, `NewsController`, `AssetsController`, `UserTradesController`, `UserFavoritesController`, `AdminController`.
- [x] SignalR WebSockets for News & Trades (`NewsHub`, `TradeHub`).
- [x] MQTT Bridge & RPC Gateway Client (`BackendMqttBridge`, `WebMqttClient`).
- [x] Complete removal of mock data in REST responses.
### Planned Features
- [ ] OAuth2 / Social Login integration (Google / Apple Sign-In).
- [ ] Two-Factor Authentication (2FA / TOTP).
@@ -33,7 +33,7 @@ public class FavoritesPriceBackgroundService(
logger.LogWarning(ex, "[FavoritesPriceBackgroundService] Error broadcasting price updates.");
}
await Task.Delay(10000, stoppingToken);
await Task.Delay(2000, stoppingToken);
}
}
@@ -50,24 +50,23 @@ public class FavoritesPriceBackgroundService(
if (userFavorites.Count == 0) return;
// 2. Pro User einfach die Kurse abfragen und senden
// 2. Pro User einfach die Kurse parallel abfragen und senden
foreach (var userGroup in userFavorites.GroupBy(f => f.UserId.ToString()))
{
var userId = userGroup.Key;
var priceUpdates = new Dictionary<string, object>();
var priceUpdates = new System.Collections.Concurrent.ConcurrentDictionary<string, object>();
foreach (var fav in userGroup)
var tasks = userGroup.Select(async fav =>
{
var cleanIsin = fav.Isin.Trim().ToUpperInvariant();
if (!mqttClient.IsConnected) continue;
if (!mqttClient.IsConnected) return;
try
{
var livePrice = await mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
"tr_GetLivePrice",
new IsinRequest(cleanIsin),
TimeSpan.FromSeconds(2)
TimeSpan.FromSeconds(4)
);
if (livePrice != null)
@@ -80,12 +79,14 @@ public class FavoritesPriceBackgroundService(
}
}
catch { /* Ignorieren bei Einzel-Timeout */ }
}
});
if (priceUpdates.Count > 0)
await Task.WhenAll(tasks);
if (!priceUpdates.IsEmpty)
{
await hubContext.Clients.Group(FavoritesPriceHub.GetGroupName(userId))
.SendAsync("ReceiveFavoritePrices", priceUpdates, cancellationToken);
.SendAsync("ReceiveFavoritePrices", new Dictionary<string, object>(priceUpdates), cancellationToken);
}
}
}
@@ -4,7 +4,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models.Trades;
using FinlyticCore.Dtos.Trading;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
@@ -17,20 +17,12 @@ public interface IFirebaseNotificationService
/// <summary>
/// Sends a push notification about a new trade proposal.
/// </summary>
/// <param name="proposal">The trade proposal details.</param>
/// <param name="fcmTokens">The list of FCM device tokens.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a push notification about an update to an existing trade.
/// </summary>
/// <param name="update">The trade update details.</param>
/// <param name="fcmTokens">The list of FCM device tokens.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
}
/// <inheritdoc />
@@ -39,11 +31,6 @@ public class FirebaseNotificationService : IFirebaseNotificationService
private readonly HttpClient _httpClient;
private readonly ILogger<FirebaseNotificationService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FirebaseNotificationService"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client for making API requests.</param>
/// <param name="logger">The logger instance.</param>
public FirebaseNotificationService(HttpClient httpClient, ILogger<FirebaseNotificationService> logger)
{
_httpClient = httpClient;
@@ -51,13 +38,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
}
/// <inheritdoc />
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
string title = $"🚀 Trade Signal: {proposal.SignalType} {proposal.Symbol}";
string body = $"{proposal.CompanyName} ({proposal.Isin}) - Entry: ${proposal.EntryPrice:F2}, WinRate: {proposal.WinRate:F1}%. {proposal.Reasoning}";
string title = $"🚀 Trade Signal: {proposal.Direction} {proposal.Symbol} ({proposal.StrategyKey})";
string body = $"{proposal.Symbol} ({proposal.UnderlyingIsin}) - Entry: {proposal.EntryPrice:F2}, Score: {proposal.CompositeScore:F0} Pkt. {proposal.AiValidation?.ThesisSummary}";
foreach (var token in fcmTokens)
{
@@ -66,12 +52,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
}
/// <inheritdoc />
public async Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default)
public async Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
string title = $"📊 Trade Update: {update.TradeId}";
string body = $"Recommendation: {update.Recommendation} @ ${update.CurrentPrice:F2}. {update.Reasoning}";
string title = $"📊 Trade Update: {update.Symbol} (Status: {update.Status})";
string body = $"Status: {update.Status} @ {update.CurrentPrice:F2}, PnL: {update.UnrealizedPnlPercent:+0.0;-0.0}% (€{update.UnrealizedPnlEur:+0.00;-0.00}).";
foreach (var token in fcmTokens)
{
+8 -6
View File
@@ -38,16 +38,18 @@ public class JwtTokenService : IJwtTokenService
{
var configuredKey = configuration["JWT:SecretKey"] ?? configuration["JWT__SecretKey"];
// Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits)
// Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits).
// Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
// das mit einem repo-öffentlichen Signing-Key läuft, ist schlimmer als eines, das nicht startet.
if (string.IsNullOrWhiteSpace(configuredKey) || configuredKey.Length < 32)
{
_secretKey = "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
}
else
{
_secretKey = configuredKey;
throw new InvalidOperationException(
"JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " +
"JwtTokenService kann ohne einen ausreichend starken, explizit konfigurierten Signing-Key nicht initialisiert werden.");
}
_secretKey = configuredKey;
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
_audience = configuration["JWT:Audience"] ?? configuration["JWT__Audience"] ?? "FinlyticClients";
_expiryDays = int.TryParse(configuration["JWT:ExpiryDays"] ?? configuration["JWT__ExpiryDays"], out var days) ? days : 7;
@@ -65,11 +65,11 @@ public class SystemHealthBackgroundService : BackgroundService
{
("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"),
("FinlyticTechnicals", "health_Ping/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "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"),
("FinlyticEngine", "health_Ping/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
("FinlyticBot", "health_Ping/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
};
var results = new List<ServiceHealthStatusDto>
+12 -55
View File
@@ -21,9 +21,6 @@ public interface IUserService
/// <summary>Authenticates user credentials and returns JWT response.</summary>
Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Registers a new user account.</summary>
Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
/// <summary>Changes the initial password for a user.</summary>
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default);
@@ -115,54 +112,6 @@ public class UserService : IUserService
};
}
/// <inheritdoc />
public async Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
if (exists)
{
_logger.LogWarning("[{Channel}] Registration failed: Email '{Email}' is already taken.", "AuthChannel",
request.Email);
return null;
}
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
var newUser = new UserEntity
{
Email = normalizedEmail,
PasswordHash = passwordHash,
FullName = string.IsNullOrWhiteSpace(request.FullName) ? normalizedEmail.Split('@')[0] : request.FullName,
Role = "User",
ThemePreference = "fluent_dark", // Default Theme for FluentAvalonia
IsActive = true,
CreatedAt = DateTime.UtcNow,
LastLoginAt = DateTime.UtcNow
};
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);
var (token, expiresAt) = _jwtTokenService.GenerateToken(newUser);
return new AuthResponseDto
{
Token = token,
UserId = newUser.Id,
Email = newUser.Email,
FullName = newUser.FullName,
Role = newUser.Role,
ThemePreference = newUser.ThemePreference,
FcmTokens = new List<string>(),
ExpiresAt = expiresAt
};
}
/// <inheritdoc />
public async Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request,
CancellationToken cancellationToken = default)
@@ -352,13 +301,21 @@ public class UserService : IUserService
if (!exists)
{
string password = !string.IsNullOrWhiteSpace(defaultAdminPassword)
? defaultAdminPassword
: "AdminDefaultPassword2026!";
// Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
// das den Default-Admin mit einem repo-öffentlichen Passwort anlegt, ist schlimmer als eines,
// das nicht startet. Program.cs validiert dies bereits vor dem Aufruf; dieser Guard dient als
// Verteidigung in der Tiefe, falls die Methode von anderer Stelle aufgerufen wird.
if (string.IsNullOrWhiteSpace(defaultAdminPassword))
{
throw new InvalidOperationException(
"ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
"Das Seeding des Default-Admin-Accounts kann ohne explizit konfiguriertes Passwort nicht durchgeführt werden.");
}
var adminUser = new UserEntity
{
Email = adminEmail,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(defaultAdminPassword),
FullName = "System Administrator",
Role = "Admin",
ThemePreference = "fluent_dark",
@@ -0,0 +1,19 @@
using FinlyticCore.Models.Settings;
namespace FinlyticBackend.Settings;
/// <summary>
/// FinlyticBackend's own dynamic settings - previously nonexistent (it had no <c>ISettingsDbContext</c>/
/// <c>ISettingsService</c> registration at all), which is why the admin UI's per-service settings screen never
/// showed anything for it, unlike every other service (see <c>EngineSettingKeys</c>/<c>SimulationSettingKeys</c>
/// for the same pattern elsewhere).
/// </summary>
public static class BackendSettingKeys
{
// --- Logging Channels ---
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> BackendChannel = new("Logging.Channel.Backend", true);
public static readonly SettingKey<bool> AuthChannel = new("Logging.Channel.Auth", true);
public static readonly SettingKey<bool> PushNotificationChannel = new("Logging.Channel.PushNotification", true);
}
+147 -107
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -7,11 +8,13 @@ using System.Threading.Tasks;
using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
using FinlyticBackend.Services;
using FinlyticBackend.Settings;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Logging;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Models;
using FinlyticCore.Models.Auth;
using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using FinlyticCore.Util;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -23,85 +26,98 @@ using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Util;
/// <summary>
/// Central Managed MQTT Bridge & RPC Gateway for FinlyticBackend.
/// Subscribes to general broadcast MQTT topics and forwards them to SignalR clients and FCM push services.
/// </summary>
public class BackendMqttBridge : ManagedMqttClient, IHostedService
{
public static readonly ConcurrentDictionary<string, JsonElement> FundamentalsCache = new(StringComparer.OrdinalIgnoreCase);
public static readonly ConcurrentDictionary<string, JsonElement> TechnicalsCache = new(StringComparer.OrdinalIgnoreCase);
public static readonly ConcurrentDictionary<string, ConcurrentQueue<LogMessageDto>> ServiceLogsRingBuffer = new(StringComparer.OrdinalIgnoreCase);
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHubContext<TradeRealtimeHub, ITradeClient> _hubContext;
private readonly IHubContext<TradeHub> _tradeHubContext;
private readonly IHubContext<TradeStreamHub, ITradeStreamClient> _tradeStreamHubContext;
private readonly IHubContext<NewsHub> _newsHubContext;
private readonly IHubContext<LogStreamHub> _logHubContext;
private readonly IFirebaseNotificationService _firebaseService;
private readonly ILogger<BackendMqttBridge> _logger;
private readonly IFinlyticLogger<BackendMqttBridge> _finlyticLogger;
public BackendMqttBridge(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
IHubContext<TradeRealtimeHub, ITradeClient> hubContext,
IHubContext<TradeHub> tradeHubContext,
IHubContext<TradeStreamHub, ITradeStreamClient> tradeStreamHubContext,
IHubContext<NewsHub> newsHubContext,
IHubContext<LogStreamHub> logHubContext,
IFirebaseNotificationService firebaseService,
ILogger<BackendMqttBridge> logger) : base(logger)
ILogger<BackendMqttBridge> logger,
IFinlyticLogger<BackendMqttBridge> finlyticLogger) : base(logger)
{
_configuration = configuration;
_scopeFactory = scopeFactory;
_hubContext = hubContext;
_tradeHubContext = tradeHubContext;
_tradeStreamHubContext = tradeStreamHubContext;
_newsHubContext = newsHubContext;
_logHubContext = logHubContext;
_firebaseService = firebaseService;
_logger = logger;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = new MqttConfiguration
{
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_backend_bridge")}_{Guid.NewGuid()}"
};
var config = MqttConfiguration.FromConfiguration(_configuration, "finlytic_backend_gateway");
_logger.LogInformation("Starting Backend MQTT Bridge. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
_logger.LogInformation("Starting Backend MQTT Gateway Bridge. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Backend MQTT Bridge.");
_logger.LogInformation("Stopping Backend MQTT Gateway Bridge.");
await DisconnectAsync();
}
/// <inheritdoc />
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("Backend MQTT Bridge connected. Subscribing to broadcast topics...");
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
"[BackendMqttBridge] Backend MQTT Gateway Bridge connected. Subscribing to broadcast topics...");
await SubscribeAsync("finlytic/trades/proposed/#");
await SubscribeAsync("finlytic/trades/updates/#");
// RPC response stream
await SubscribeAsync(MqttTopics.ResponseWildcard);
// News topics
await SubscribeAsync("services/news/completed");
await SubscribeAsync("finlytic/news/#");
await SubscribeAsync("finlytic/sentiment/#");
// Engine & Bot streams
await SubscribeAsync(MqttTopics.EngineWildcard);
await SubscribeAsync(MqttTopics.BotWildcard);
// Fundamentals & Technicals
await SubscribeAsync("finlytic/fundamentals/#");
await SubscribeAsync("finlytic/assets/fundamentals/#");
await SubscribeAsync("finlytic/technicalanalysis/#");
await SubscribeAsync("finlytic/ta/#");
// News & Sentiment streams
await SubscribeAsync(MqttTopics.NewsCompleted);
await SubscribeAsync(MqttTopics.NewsStreamWildcard);
await SubscribeAsync(MqttTopics.SentimentWildcard);
// Real-time Logs
await SubscribeAsync("finlytic/logs/#");
await SubscribeAsync(MqttTopics.LogsWildcard);
// Universe RPC Endpoint for FinlyticTechnicals
await SubscribeRpcAsync<object, List<string>>(
MqttTopics.RequestFilter(MqttTopics.Channels.BackendGetAggregatedFavorites),
HandleGetAggregatedFavoritesRpcAsync);
// FinlyticBackend previously never broadcast its OWN structured logs at all (it only relayed other
// services' logs received on MqttTopics.LogsWildcard, subscribed above) - it never used
// IFinlyticLogger<T>, so FinlyticLogBroadcaster.Broadcast was never invoked for anything happening
// inside FinlyticBackend itself. This hook publishes FinlyticBackend's own logs onto the exact same
// finlytic/logs/FinlyticBackend topic every other service publishes to, which the wildcard
// subscription above then picks straight back up and relays into LogStreamHub like any other
// service's messages - no separate "local echo" mechanism needed.
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticBackend", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync(MqttTopics.Logs("FinlyticBackend"), logDto);
}
};
}
/// <inheritdoc />
@@ -111,39 +127,33 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
try
{
if (topic.StartsWith("finlytic/logs/", StringComparison.OrdinalIgnoreCase))
if (topic.StartsWith(MqttTopics.LogsPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleLogMessageAsync(payloadStr);
}
else if (topic.StartsWith("finlytic/trades/proposed/", StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith("finlytic/trades/update", StringComparison.OrdinalIgnoreCase))
else if (topic.StartsWith(MqttTopics.EngineProposalsPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleTradeProposalAsync(payloadStr);
await HandleEngineProposalAsync(payloadStr);
}
else if (topic.StartsWith("finlytic/trades/updates/", StringComparison.OrdinalIgnoreCase))
else if (topic.StartsWith(MqttTopics.EngineTradesPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleTradeUpdateAsync(payloadStr);
await HandleEngineTradeUpdateAsync(payloadStr);
}
else if (topic.Equals("services/news/completed", StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith("finlytic/news/", StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith("finlytic/sentiment/", StringComparison.OrdinalIgnoreCase))
else if (topic.StartsWith(MqttTopics.BotTradesPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleBotTradeUpdateAsync(payloadStr);
}
else if (topic.Equals(MqttTopics.NewsCompleted, StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith(MqttTopics.NewsPrefix, StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleNewsArticleAsync(payloadStr);
}
else if (topic.StartsWith("finlytic/fundamentals/", StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith("finlytic/assets/fundamentals/", StringComparison.OrdinalIgnoreCase))
{
HandleFundamentalsCache(topic, payloadStr);
}
else if (topic.StartsWith("finlytic/technicalanalysis/", StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith("finlytic/ta/", StringComparison.OrdinalIgnoreCase))
{
HandleTechnicalsCache(topic, payloadStr);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing message in Backend MQTT Bridge on topic {Topic}", topic);
await _finlyticLogger.LogErrorAsync(BackendSettingKeys.BackendChannel, ex,
"[BackendMqttBridge] Error processing message on topic {Topic}", topic);
}
}
@@ -159,49 +169,94 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
// Keep buffer capped at 250 entries
while (queue.Count > 250 && queue.TryDequeue(out _)) { }
// Broadcast to SignalR clients
await _logHubContext.Clients.Group(serviceKey).SendAsync("ReceiveLogMessage", logDto);
// Broadcast once to all connected SignalR admin logs clients
await _logHubContext.Clients.All.SendAsync("ReceiveLogMessage", logDto);
}
private async Task HandleTradeProposalAsync(string payloadStr)
private async Task HandleEngineProposalAsync(string payloadStr)
{
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr);
if (proposal == null) return;
await _hubContext.Clients.All.OnTradeProposed(proposal);
await _tradeHubContext.Clients.All.SendAsync("ReceiveTradeUpdate", proposal);
_logger.LogInformation("Broadcasted Trade Proposal {AnalysisId} via SignalR.", proposal.AnalysisId);
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
if (fcmTokens.Count > 0)
await _tradeStreamHubContext.Clients.All.ReceiveTradeProposal(proposal);
if (!string.IsNullOrWhiteSpace(proposal.UnderlyingIsin))
{
await _firebaseService.SendTradeProposalNotificationAsync(proposal, fcmTokens);
await _tradeStreamHubContext.Clients.Group(proposal.UnderlyingIsin.ToUpperInvariant()).ReceiveTradeProposal(proposal);
}
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted FinlyticEngine Trade Proposal {ProposalId} for {Isin} via SignalR TradeStreamHub.", proposal.ProposalId, proposal.UnderlyingIsin);
// FCM Push Notification for High Score Proposals (Score >= 75)
if (proposal.CompositeScore >= 75)
{
try
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
if (fcmTokens.Count > 0)
{
await _firebaseService.SendTradeProposalNotificationAsync(proposal, fcmTokens);
}
}
catch (Exception ex)
{
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
"[BackendMqttBridge] Failed to send FCM push notification for proposal {ProposalId}.", proposal.ProposalId);
}
}
}
private async Task HandleTradeUpdateAsync(string payloadStr)
private async Task HandleEngineTradeUpdateAsync(string payloadStr)
{
var update = JsonSerializer.Deserialize<TradeHourlyUpdateDto>(payloadStr);
if (update == null) return;
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(payloadStr);
if (trade == null) return;
await _hubContext.Clients.All.OnTradeUpdated(update);
await _tradeHubContext.Clients.All.SendAsync("ReceiveTradeUpdate", update);
if (string.Equals(update.Recommendation, "Close", StringComparison.OrdinalIgnoreCase) ||
string.Equals(update.Recommendation, "AdjustSL", StringComparison.OrdinalIgnoreCase))
await _tradeStreamHubContext.Clients.All.ReceiveTradeUpdate(trade);
if (!string.IsNullOrWhiteSpace(trade.UnderlyingIsin))
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
await _tradeStreamHubContext.Clients.Group(trade.UnderlyingIsin.ToUpperInvariant()).ReceiveTradeUpdate(trade);
}
if (fcmTokens.Count > 0)
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted FinlyticEngine Trade Update {TradeId} (Status: {Status}) via SignalR TradeStreamHub.", trade.TradeId, trade.Status);
// Push notification on important state transitions (e.g. stopped out or TP2 hit)
if (trade.Status == TradeStatus.StoppedOut || trade.Status == TradeStatus.Tp2Hit || trade.Status == TradeStatus.Closed)
{
try
{
await _firebaseService.SendTradeUpdateNotificationAsync(update, fcmTokens);
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
if (fcmTokens.Count > 0)
{
await _firebaseService.SendTradeUpdateNotificationAsync(trade, fcmTokens);
}
}
catch (Exception ex)
{
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
"[BackendMqttBridge] Failed to send FCM push notification for trade update {TradeId}.", trade.TradeId);
}
}
}
private async Task HandleBotTradeUpdateAsync(string payloadStr)
{
var botTrade = JsonSerializer.Deserialize<BotTradeOrderDto>(payloadStr, DefaultJsonOptions);
if (botTrade != null)
{
await _tradeStreamHubContext.Clients.All.ReceiveBotPositionUpdate(botTrade);
if (!string.IsNullOrWhiteSpace(botTrade.Isin))
{
await _tradeStreamHubContext.Clients.Group(botTrade.Isin.ToUpperInvariant()).ReceiveBotPositionUpdate(botTrade);
}
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted Bot trade update for {Isin} ({Symbol}, Status: {Status}) via SignalR TradeStreamHub.", botTrade.Isin, botTrade.Symbol, botTrade.Status);
}
}
@@ -211,39 +266,24 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
if (article == null) return;
await _newsHubContext.Clients.All.SendAsync("ReceiveNewArticle", article);
_logger.LogInformation("Broadcasted live news item '{Title}' over SignalR NewsHub.", article.Title);
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted live news item '{Title}' over SignalR NewsHub.", article.Title);
}
private void HandleFundamentalsCache(string topic, string payloadStr)
private async Task<List<string>> HandleGetAggregatedFavoritesRpcAsync(object? _, string correlationId)
{
using var jsonDoc = JsonDocument.Parse(payloadStr);
var root = jsonDoc.RootElement.Clone();
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null;
string? ticker = root.TryGetProperty("primaryTicker", out var tickerProp)
? tickerProp.GetString()
: (root.TryGetProperty("ticker", out var tProp) ? tProp.GetString() : null);
var isins = await dbContext.UserFavoriteAssets
.AsNoTracking()
.Select(f => f.Isin)
.Where(isin => !string.IsNullOrWhiteSpace(isin))
.Distinct()
.ToListAsync();
if (!string.IsNullOrEmpty(isin)) FundamentalsCache[isin] = root;
if (!string.IsNullOrEmpty(ticker)) FundamentalsCache[ticker] = root;
string topicKey = topic.Split('/').LastOrDefault() ?? string.Empty;
if (!string.IsNullOrEmpty(topicKey)) FundamentalsCache[topicKey] = root;
_logger.LogInformation("Cached fundamentals payload from MQTT topic {Topic}.", topic);
}
private void HandleTechnicalsCache(string topic, string payloadStr)
{
using var jsonDoc = JsonDocument.Parse(payloadStr);
var root = jsonDoc.RootElement.Clone();
string? symbol = root.TryGetProperty("symbol", out var sProp) ? sProp.GetString() : null;
if (!string.IsNullOrEmpty(symbol)) TechnicalsCache[symbol] = root;
string topicKey = topic.Split('/').LastOrDefault() ?? string.Empty;
if (!string.IsNullOrEmpty(topicKey)) TechnicalsCache[topicKey] = root;
_logger.LogInformation("Cached technical analysis payload from MQTT topic {Topic}.", topic);
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
"[BackendMqttBridge] Responded to backend_GetAggregatedFavorites with {Count} unique ISINs. [CorrelationId: {CorrelationId}]", isins.Count, correlationId);
return isins;
}
}
+21 -68
View File
@@ -1,91 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models;
using FinlyticCore.Util;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Util;
/// <summary>
/// Managed MQTT client for web gateway endpoints enabling RPC communication with background microservices.
/// Backward-compatible bridge forwarding RPC requests to the primary singleton <see cref="BackendMqttBridge"/>.
/// Prevents secondary MQTT connection instantiations in the gateway process.
/// </summary>
public class WebMqttClient : ManagedMqttClient, IHostedService
public class WebMqttClient : IHostedService
{
private readonly ILogger<WebMqttClient> _logger;
private readonly IConfiguration _configuration;
private readonly BackendMqttBridge _bridge;
public WebMqttClient(ILogger<WebMqttClient> logger, IConfiguration configuration) : base(logger)
public bool IsConnected => _bridge.IsConnected;
public WebMqttClient(BackendMqttBridge bridge)
{
_logger = logger;
_configuration = configuration;
_bridge = bridge;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = new MqttConfiguration
{
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_backend_rpc")}_{Guid.NewGuid()}"
};
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
_logger.LogInformation("Starting Web MQTT RPC Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest request, TimeSpan timeout)
where TResponse : class
where TRequest : class
{
return _bridge.SendRpcRequestAsync<TResponse, TRequest>(channel, request, timeout);
}
public async Task StopAsync(CancellationToken cancellationToken)
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest request)
where TResponse : class
where TRequest : class
{
_logger.LogInformation("Stopping Web MQTT RPC Client.");
await DisconnectAsync();
return _bridge.SendRpcRequestAsync<TResponse, TRequest>(channel, request);
}
protected override async Task OnConnectedAsync()
public Task PublishAsync<T>(string topic, T payload)
{
_logger.LogInformation("Web MQTT RPC client connected. Subscribing to RPC response channels...");
await SubscribeAsync("services/response/news_Get/#");
await SubscribeAsync("services/response/news_GetDaily/#");
await SubscribeAsync("services/response/news_GetById/#");
await SubscribeAsync("services/response/sentiment_GetArticle/#");
await SubscribeAsync("services/response/sentiment_GetIsin/#");
await SubscribeAsync("services/response/sentiment_Analyze/#");
await SubscribeAsync("services/response/fundamentals_Get/#");
await SubscribeAsync("services/response/events_GetAll/#");
await SubscribeAsync("services/response/events_GetByMonth/#");
await SubscribeAsync("services/response/ta_GetAnalysis/#");
await SubscribeAsync("services/response/tr_GetLivePrice/#");
await SubscribeAsync("services/response/assets_Get/#");
await SubscribeAsync("services/response/assets_Search/#");
await SubscribeAsync("services/response/assets_GetDiscovery/#");
await SubscribeAsync("services/response/assets_GetDerivatives/#");
await SubscribeAsync("services/response/trades_Get/#");
await SubscribeAsync("services/response/trades_Close/#");
await SubscribeAsync("services/response/trades_Reject/#");
await SubscribeAsync("services/response/trades_Accept/#");
await SubscribeAsync("services/response/analyzer_TriggerManual/#");
await SubscribeAsync("services/response/health_Ping/#");
// Settings RPC response channels for all microservices
await SubscribeAsync("services/response/fundamentals_settings_GetAll/#");
await SubscribeAsync("services/response/fundamentals_settings_Update/#");
await SubscribeAsync("services/response/news_settings_GetAll/#");
await SubscribeAsync("services/response/news_settings_Update/#");
await SubscribeAsync("services/response/ta_settings_GetAll/#");
await SubscribeAsync("services/response/ta_settings_Update/#");
await SubscribeAsync("services/response/sentiment_settings_GetAll/#");
await SubscribeAsync("services/response/sentiment_settings_Update/#");
await SubscribeAsync("services/response/analyzer_settings_GetAll/#");
await SubscribeAsync("services/response/analyzer_settings_Update/#");
await SubscribeAsync("services/response/trades_settings_GetAll/#");
await SubscribeAsync("services/response/trades_settings_Update/#");
await SubscribeAsync("services/response/assets_settings_GetAll/#");
await SubscribeAsync("services/response/assets_settings_Update/#");
}
protected override Task OnMessageReceivedAsync(string topic, string payload)
{
return Task.CompletedTask;
return _bridge.PublishAsync(topic, payload);
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=finlytic_backend;Username=postgres;Password=postgres"
},
"MQTT": {
"Host": "localhost",
"Port": 1883,
"ClientId": "finlytic_backend"
},
"JWT": {
"Issuer": "FinlyticBackend",
"Audience": "FinlyticClients"
}
}
+1 -1
View File
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"0cd610717bde95fd88343c64f81c11ba4e5c00
_flutter.loader.load({
serviceWorkerSettings: {
serviceWorkerVersion: "422571355" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
serviceWorkerVersion: "516861606" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
}
});
File diff suppressed because one or more lines are too long