using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Settings; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models; using FinlyticCore.Services; using FinlyticCore.Util; using FinlyticTechnicals.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace FinlyticTechnicals.Util; public record GetSetupsRequest( bool TopPicksOnly = false, int Limit = 50, decimal? MinScore = null ); public record GetCandlesRequest( string Isin = "", string Timeframe = "15m" ); public class TAMqttClient : ManagedMqttClient, IHostedService, ITAMqttRpcClient { private readonly IConfiguration _configuration; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public TAMqttClient( 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, "FinlyticTechnicals"); _logger.LogInformation("Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); await ConnectAsync(config); } public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Stopping Technical Analysis MQTT client."); await DisconnectAsync(); } protected override async Task OnConnectedAsync() { _logger.LogInformation("Technical Analysis MQTT client connected. Registering RPC endpoints..."); await SubscribeAsync(MqttTopics.ResponseWildcard); await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetAnalysis), HandleGetAnalysisRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetupsForIsin), HandleGetSetupsForIsinRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetups), HandleGetSetupsRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetCandles), HandleGetCandlesRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetWatchlist), HandleGetWatchlistRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetRecentSetupHistory), HandleGetRecentSetupHistoryRpcAsync); await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsGetAll), HandleSettingsGetAllRpcAsync); await SubscribeRpcAsync, List>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsUpdate), HandleSettingsUpdateRpcAsync); await SubscribeAsync(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync); // Subscribe to Sentiment Spikes & Stream await SubscribeAsync(MqttTopics.SentimentWildcard); FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => { if (IsConnected && (string.Equals(logDto.ServiceName, "FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || string.Equals(logDto.ServiceName, "FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase))) { await PublishAsync(MqttTopics.Logs("FinlyticTechnicals"), logDto); } }; } protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) { if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return; try { if (topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase)) { await HandleSentimentEventAsync(topic, payloadStr); } } catch (Exception ex) { _logger.LogError(ex, "[TAMqttClient] Error handling message on topic {Topic}", topic); } } private async Task HandleSentimentEventAsync(string topic, string payloadStr) { using var jsonDoc = JsonDocument.Parse(payloadStr); var root = jsonDoc.RootElement; string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null; if (string.IsNullOrWhiteSpace(isin)) { // Try extracting from topic if stream format: finlytic/sentiment/stream/{isin} var parts = topic.Split('/'); if (parts.Length >= 4 && parts[2].Equals("stream", StringComparison.OrdinalIgnoreCase)) { isin = parts[3]; } } if (string.IsNullOrWhiteSpace(isin)) return; double compoundScore = 0.0; string? trend = null; if (root.TryGetProperty("currentSummary", out var currSummary)) { if (currSummary.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble(); if (currSummary.TryGetProperty("trend", out var trProp)) trend = trProp.GetString(); } else { if (root.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble(); if (root.TryGetProperty("trend", out var trProp)) trend = trProp.GetString(); } bool isSpike = Math.Abs(compoundScore) >= 0.5 || string.Equals(trend, "IMPROVING", StringComparison.OrdinalIgnoreCase) || string.Equals(trend, "DETERIORATING", StringComparison.OrdinalIgnoreCase); if (isSpike) { using var scope = _scopeFactory.CreateScope(); var universeManager = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); await universeManager.AddOrUpdateAssetAsync(isin, null, UniverseSource.SentimentSpike, priority: 1, ttl: TimeSpan.FromMinutes(120)); await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Sentiment Spike event detected on {Topic} for ISIN {Isin} (Compound={Score:F2}, Trend={Trend}) -> Promoted to Priority 1 (TTL 120m)", topic, isin, compoundScore, trend ?? "N/A"); } } private async Task HandleGetAnalysisRpcAsync(IsinRequest? req, string correlationId) { if (string.IsNullOrWhiteSpace(req?.Isin)) return null; using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var scoringEngine = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetAnalysis for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId); return await scoringEngine.GetTechnicalAnalysisDtoAsync(req.Isin, req.Ticker); } private async Task> HandleGetSetupsForIsinRpcAsync(IsinRequest? req, string correlationId) { if (string.IsNullOrWhiteSpace(req?.Isin)) return []; using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var scoringEngine = scope.ServiceProvider.GetRequiredService(); var universeManager = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetupsForIsin for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId); // Attach the current universe-selection reason (if the ISIN is actively monitored) so a caller (e.g. // FinlyticEngine's TradeLifecycleService) can record WHY this asset was being watched, not just its // scores. Stays null for an ISIN nobody favorited/discovered/spiked - an honest ad hoc analysis. var universeEntry = await universeManager.GetEntryAsync(req.Isin); return await scoringEngine.AnalyzeIsinAsync(req.Isin, req.Ticker, universeEntry?.Source, universeEntry?.AddedAtUtc); } private async Task> HandleGetSetupsRpcAsync(GetSetupsRequest? req, string correlationId) { using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var scoringEngine = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetups (TopPicks: {TopPicks}, Limit: {Limit}, MinScore: {MinScore}) [CorrelationId: {CorrelationId}]", req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore?.ToString() ?? "null", correlationId); return await scoringEngine.GetActiveSetupsAsync(req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore); } private async Task> HandleGetWatchlistRpcAsync(object? req, string correlationId) { using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var universeManager = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetWatchlist [CorrelationId: {CorrelationId}]", correlationId); var universe = await universeManager.GetActiveUniverseAsync(); return universe.Select(e => new WatchlistEntryDto(e.Isin, e.Symbol, e.Source.ToString(), e.Priority, e.AddedAtUtc, e.ExpiresAtUtc)).ToList(); } private async Task> HandleGetRecentSetupHistoryRpcAsync(GetRecentSetupHistoryRequest? req, string correlationId) { if (string.IsNullOrWhiteSpace(req?.Isin)) return []; using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var scoringEngine = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetRecentSetupHistory for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId); return await scoringEngine.GetRecentSetupHistoryAsync(req.Isin, req.Limit); } private Task> HandleGetCandlesRpcAsync(GetCandlesRequest? req, string correlationId) { if (string.IsNullOrWhiteSpace(req?.Isin)) return Task.FromResult>([]); using var scope = _scopeFactory.CreateScope(); var aggregator = scope.ServiceProvider.GetRequiredService(); var candles = aggregator.GetCandles(req.Isin, req.Timeframe ?? "15m"); return Task.FromResult(candles); } private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) { using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var settingsService = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_GetAll] Retrieving dynamic settings [CorrelationId: {CorrelationId}]", correlationId); return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); } private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) { using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var settingsService = scope.ServiceProvider.GetRequiredService(); await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_Update] Processing settings update [CorrelationId: {CorrelationId}]", correlationId); if (updates != null && updates.Count > 0) { await settingsService.UpdateSettingsAsync(updates); await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] Successfully updated {Count} settings in database and cache.", updates.Count); } return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); } private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId) { if (topic.Contains("FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || topic.Contains("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase)) { string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticTechnicals", "Online", DateTime.UtcNow, "Connected")); using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); await logger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticTechnicals] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId); } } }