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
+149 -109
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 SubscribeAsync("finlytic/trades/proposed/#");
await SubscribeAsync("finlytic/trades/updates/#");
// News topics
await SubscribeAsync("services/news/completed");
await SubscribeAsync("finlytic/news/#");
await SubscribeAsync("finlytic/sentiment/#");
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
"[BackendMqttBridge] Backend MQTT Gateway Bridge connected. Subscribing to broadcast topics...");
// Fundamentals & Technicals
await SubscribeAsync("finlytic/fundamentals/#");
await SubscribeAsync("finlytic/assets/fundamentals/#");
await SubscribeAsync("finlytic/technicalanalysis/#");
await SubscribeAsync("finlytic/ta/#");
// RPC response stream
await SubscribeAsync(MqttTopics.ResponseWildcard);
// Engine & Bot streams
await SubscribeAsync(MqttTopics.EngineWildcard);
await SubscribeAsync(MqttTopics.BotWildcard);
// 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;
}
}