using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Settings; using FinlyticCore.Dtos.Trading; using FinlyticCore.Models; using FinlyticCore.Services; using FinlyticCore.Util; using FinlyticEngine.Services.Mqtt; using FinlyticEngine.Services.Trading; using FinlyticEngine.Settings; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace FinlyticEngine.Util; public class EngineMqttClient : ManagedMqttClient, IHostedService, IEngineRpcClient { private readonly IConfiguration _configuration; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public EngineMqttClient( ILogger logger, IConfiguration configuration, IServiceScopeFactory scopeFactory) : base(logger) { _logger = logger; _configuration = configuration; _scopeFactory = scopeFactory; } public async Task StartAsync(CancellationToken cancellationToken) { var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticEngine"); _logger.LogInformation("Starting FinlyticEngine MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); await ConnectAsync(config); } public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Stopping FinlyticEngine MQTT client."); await DisconnectAsync(); } protected override async Task OnConnectedAsync() { _logger.LogInformation("FinlyticEngine MQTT client connected. Registering RPC endpoints..."); await SubscribeAsync(MqttTopics.ResponseWildcard); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetProposals), HandleGetProposalsRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetTrades), HandleGetTradesRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineEvaluateIsin), HandleEvaluateIsinRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetEvaluationHistory), HandleGetEvaluationHistoryRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineAddFill), HandleAddFillRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineUpdateStopLoss), HandleUpdateStopLossRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineCloseTrade), HandleCloseTradeRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineAcceptProposal), HandleAcceptProposalRpcAsync); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineCreateManualTrade), HandleCreateManualTradeRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineSettingsGetAll), HandleSettingsGetAllRpcAsync); await SubscribeRpcAsync, List>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineSettingsUpdate), HandleSettingsUpdateRpcAsync); await SubscribeAsync(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync); FinlyticLogBroadcaster.OnLogPublished = async (logDto) => { if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticEngine", StringComparison.OrdinalIgnoreCase)) { await PublishAsync(MqttTopics.Logs("FinlyticEngine"), logDto); } }; } private async Task> HandleGetProposalsRpcAsync(GetTradeProposalsRequest? req, string correlationId) { using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.GetProposalsAsync(req?.OnlyActive ?? true, req?.Limit ?? 50); } private async Task> HandleGetTradesRpcAsync(GetActiveTradesRequest? req, string correlationId) { if (req == null) throw new ArgumentNullException(nameof(req)); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.GetActiveTradesAsync(req.UserId, req.Mode); } private async Task HandleEvaluateIsinRpcAsync(EvaluateAssetRequest? req, string correlationId) { // A blank/missing ISIN is no longer a special case here: EvaluateAssetAsync now always returns a // populated AssetEvaluationResultDto (never null), including for a blank ISIN, so it is safe to just // delegate straight through. // // This RPC channel is only ever reached from the manual, on-demand Web UI flows // (AnalyzeController.TriggerManualAnalysis / EngineController.EvaluateAsset) - the autonomous // OpportunityPollerBackgroundService calls ITradeLifecycleService.EvaluateAssetAsync directly // in-process and never goes through MQTT for it - so TriggerSource is always Manual here. UserId comes // from EvaluateAssetRequest.UserId, which FinlyticBackend always overwrites server-side with the JWT // identity before publishing the request (see EvaluateAssetRequest's doc comment); Guid.Empty (the // request's own default) is treated as "no identity available" rather than a real user ID. using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); Guid? triggeredByUserId = req != null && req.UserId != Guid.Empty ? req.UserId : null; return await lifecycleService.EvaluateAssetAsync( req?.Isin ?? string.Empty, req?.Ticker, req?.ForceAiEvaluation ?? false, triggerSource: TriggerSource.Manual, triggeredByUserId: triggeredByUserId); } private async Task HandleGetEvaluationHistoryRpcAsync(GetEvaluationHistoryRequest? req, string correlationId) { using var scope = _scopeFactory.CreateScope(); var historyService = scope.ServiceProvider.GetRequiredService(); return await historyService.GetHistoryAsync(req ?? new GetEvaluationHistoryRequest()); } private async Task HandleAddFillRpcAsync(AddTradeFillRequest? req, string correlationId) { if (req == null) throw new ArgumentNullException(nameof(req)); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.AddTradeFillAsync(req.UserId, req.TradeId, req.ExecutedPrice, req.Quantity, req.Fee, req.Note); } private async Task HandleUpdateStopLossRpcAsync(UpdateTradeStopLossRequest? req, string correlationId) { if (req == null) throw new ArgumentNullException(nameof(req)); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.UpdateStopLossAsync(req.UserId, req.TradeId, req.NewStopLoss, req.Reason); } private async Task HandleCloseTradeRpcAsync(CloseEngineTradeRequest? req, string correlationId) { if (req == null) throw new ArgumentNullException(nameof(req)); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.CloseTradeAsync(req.UserId, req.TradeId, req.ClosePrice, req.Reason); } private async Task HandleAcceptProposalRpcAsync(AcceptTradeProposalRequest? req, string correlationId) { if (req == null) throw new ArgumentNullException(nameof(req)); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.AcceptProposalAsync(req); } private async Task HandleCreateManualTradeRpcAsync(CreateManualTradeRequest? req, string correlationId) { if (req == null) throw new ArgumentNullException(nameof(req)); using var scope = _scopeFactory.CreateScope(); var lifecycleService = scope.ServiceProvider.GetRequiredService(); return await lifecycleService.CreateManualTradeAsync(req); } private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) { using var scope = _scopeFactory.CreateScope(); var settingsService = scope.ServiceProvider.GetRequiredService(); return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(EngineSettingKeys) }); } private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) { using var scope = _scopeFactory.CreateScope(); var settingsService = scope.ServiceProvider.GetRequiredService(); if (updates != null && updates.Count > 0) { await settingsService.UpdateSettingsAsync(updates); } return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(EngineSettingKeys) }); } private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId) { if (topic.Contains("FinlyticEngine", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) { string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticEngine", "Online", DateTime.UtcNow, "Connected")); using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); await logger.LogInfoAsync(EngineSettingKeys.HealthPingChannel, "[FinlyticEngine] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId); } } }