using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Assets; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models.Assets; using FinlyticCore.Services; using FinlyticTechnicals.Database; using FinlyticTechnicals.Entities; using FinlyticTechnicals.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticTechnicals.Services; public record MonitoredUniverseEntry( string Isin, string? Symbol, UniverseSource Source, DateTime AddedAtUtc, DateTime? ExpiresAtUtc, int Priority ); public interface ITechnicalUniverseManager { Task AddOrUpdateAssetAsync(string isin, string? symbol, UniverseSource source, int priority, TimeSpan? ttl = null, CancellationToken cancellationToken = default); Task RemoveExpiredAsync(CancellationToken cancellationToken = default); Task> GetActiveUniverseAsync(CancellationToken cancellationToken = default); /// /// Looks up the current universe entry for a single ISIN, if it is currently monitored. Used by /// TAMqttClient to attach / /// onto an on-demand ta_GetSetupsForIsin analysis, so the caller (FinlyticEngine) can record why the /// asset was being watched in the first place. Returns if the ISIN is not currently /// in the universe (e.g. a manual "Analyze now" call for an asset nobody favorited/discovered/spiked). /// Task GetEntryAsync(string isin, CancellationToken cancellationToken = default); Task RefreshFavoritesAsync(CancellationToken cancellationToken = default); Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default); } /// /// Maintains the prioritized set of ISINs FinlyticTechnicals continuously scans (favorites aggregated across /// all users, FinlyticAssets' curated discovery list, and temporary sentiment-spike promotions), backed by the /// fta_monitored_universe_assets table rather than an in-memory collection so the current universe is /// inspectable in the database while the service is running. The table is deliberately wiped on every service /// startup (see Program.cs) - it is fully rebuilt within minutes from /// / and fresh sentiment-spike events, so /// nothing of value would survive a restart anyway. /// public class TechnicalUniverseManager : ITechnicalUniverseManager { private readonly IServiceScopeFactory _scopeFactory; private readonly ITAMqttRpcClient _rpcClient; private readonly IFinlyticLogger _logger; public TechnicalUniverseManager( IServiceScopeFactory scopeFactory, ITAMqttRpcClient rpcClient, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _rpcClient = rpcClient; _logger = logger; } public async Task AddOrUpdateAssetAsync(string isin, string? symbol, UniverseSource source, int priority, TimeSpan? ttl = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return; var cleanIsin = isin.Trim().ToUpperInvariant(); DateTime now = DateTime.UtcNow; DateTime? expiresAt = ttl.HasValue ? now.Add(ttl.Value) : null; using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var existing = await db.MonitoredUniverseAssets.FirstOrDefaultAsync(e => e.Isin == cleanIsin, cancellationToken); if (existing == null) { db.MonitoredUniverseAssets.Add(new FtaMonitoredUniverseAssetEntity { Isin = cleanIsin, Symbol = symbol, Source = source.ToString(), Priority = priority, AddedAtUtc = now, ExpiresAtUtc = expiresAt }); } else { // Keep the highest priority (lower int value = higher priority), matching the original in-memory // ConcurrentDictionary.AddOrUpdate semantics this table replaced. int bestPriority = Math.Min(existing.Priority, priority); if (bestPriority == priority) { existing.Source = source.ToString(); } existing.Priority = bestPriority; existing.Symbol = symbol ?? existing.Symbol; existing.ExpiresAtUtc = expiresAt != null && (existing.ExpiresAtUtc == null || expiresAt > existing.ExpiresAtUtc) ? expiresAt : existing.ExpiresAtUtc; } await db.SaveChangesAsync(cancellationToken); await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[UniverseManager] Added/Updated asset {Isin} (Source: {Source}, Priority: {Priority}, TTL: {TTL}m)", cleanIsin, source, priority, ttl?.TotalMinutes ?? 0); } public async Task RemoveExpiredAsync(CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); DateTime now = DateTime.UtcNow; var expired = await db.MonitoredUniverseAssets .Where(e => e.ExpiresAtUtc.HasValue && e.ExpiresAtUtc.Value <= now) .ToListAsync(cancellationToken); if (expired.Count == 0) return; db.MonitoredUniverseAssets.RemoveRange(expired); await db.SaveChangesAsync(cancellationToken); foreach (var removed in expired) { await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[UniverseManager] Expired temporary asset {Isin} (Source: {Source}) removed from scan universe.", removed.Isin, removed.Source); } } public async Task> GetActiveUniverseAsync(CancellationToken cancellationToken = default) { await RemoveExpiredAsync(cancellationToken); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entities = await db.MonitoredUniverseAssets .AsNoTracking() .OrderBy(e => e.Priority) .ThenByDescending(e => e.AddedAtUtc) .ToListAsync(cancellationToken); return entities.Select(ToEntry).ToList(); } public async Task GetEntryAsync(string isin, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return null; var cleanIsin = isin.Trim().ToUpperInvariant(); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entity = await db.MonitoredUniverseAssets.AsNoTracking().FirstOrDefaultAsync(e => e.Isin == cleanIsin, cancellationToken); return entity == null ? null : ToEntry(entity); } public async Task RefreshFavoritesAsync(CancellationToken cancellationToken = default) { try { var isins = await _rpcClient.SendRpcRequestAsync, string>( "backend_GetAggregatedFavorites", string.Empty, TimeSpan.FromSeconds(5) ); var freshSet = ToCleanIsinSet(isins); int prunedCount = await UpsertSourceBatchAsync(UniverseSource.UserFavorite, priority: 2, freshSet, cancellationToken); await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[UniverseManager] Synced {Count} user favorite ISINs from FinlyticBackend ({Pruned} stale entries pruned).", freshSet.Count, prunedCount); } catch (Exception ex) { await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[UniverseManager] Failed to refresh user favorites from FinlyticBackend via RPC."); } } public async Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default) { try { var req = new GetDiscoveryAssetsRequest(Limit: 35); var discoveryAssets = await _rpcClient.SendRpcRequestAsync, GetDiscoveryAssetsRequest>( "assets_GetDiscovery", req, TimeSpan.FromSeconds(5) ); var freshSet = ToCleanIsinSet(discoveryAssets?.Select(a => a.Isin)); int prunedCount = await UpsertSourceBatchAsync(UniverseSource.Discovery, priority: 3, freshSet, cancellationToken); await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[UniverseManager] Synced {Count} discovery assets from FinlyticAssets ({Pruned} stale entries pruned).", freshSet.Count, prunedCount); } catch (Exception ex) { await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[UniverseManager] Failed to refresh discovery assets from FinlyticAssets via RPC."); } } private static HashSet ToCleanIsinSet(IEnumerable? isins) { return new HashSet( (isins ?? []).Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => i.Trim().ToUpperInvariant()), StringComparer.OrdinalIgnoreCase); } private static MonitoredUniverseEntry ToEntry(FtaMonitoredUniverseAssetEntity e) => new( e.Isin, e.Symbol, Enum.TryParse(e.Source, out var src) ? src : UniverseSource.Discovery, e.AddedAtUtc, e.ExpiresAtUtc, e.Priority ); /// /// Upserts every ISIN in under / /// in a single batch, and removes rows still tagged with whose ISIN is no longer /// present in - i.e. an asset the latest refresh no longer reports (a user /// unfavorited it, or it dropped out of discovery). A row that meanwhile got promoted to a different source /// (e.g. a live sentiment spike) is left alone: its Source column no longer matches, so it survives on its /// own TTL instead of being pruned here. Returns the number of stale rows pruned. /// private async Task UpsertSourceBatchAsync(UniverseSource source, int priority, HashSet freshIsins, CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var now = DateTime.UtcNow; var sourceTag = source.ToString(); var all = await db.MonitoredUniverseAssets.ToListAsync(cancellationToken); var byIsin = all.ToDictionary(e => e.Isin, e => e, StringComparer.OrdinalIgnoreCase); foreach (var isin in freshIsins) { if (byIsin.TryGetValue(isin, out var existing)) { int bestPriority = Math.Min(existing.Priority, priority); if (bestPriority == priority) { existing.Source = sourceTag; } existing.Priority = bestPriority; } else { db.MonitoredUniverseAssets.Add(new FtaMonitoredUniverseAssetEntity { Isin = isin, Symbol = null, Source = sourceTag, Priority = priority, AddedAtUtc = now, ExpiresAtUtc = null }); } } var stale = all.Where(e => e.Source == sourceTag && !freshIsins.Contains(e.Isin)).ToList(); if (stale.Count > 0) { db.MonitoredUniverseAssets.RemoveRange(stale); } await db.SaveChangesAsync(cancellationToken); return stale.Count; } }