Files
Finlytic/FinlyticTechnicals/Services/TechnicalUniverseManager.cs

284 lines
12 KiB
C#

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<IReadOnlyList<MonitoredUniverseEntry>> GetActiveUniverseAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Looks up the current universe entry for a single ISIN, if it is currently monitored. Used by
/// <c>TAMqttClient</c> to attach <see cref="MonitoredUniverseEntry.Source"/>/<see cref="MonitoredUniverseEntry.AddedAtUtc"/>
/// onto an on-demand <c>ta_GetSetupsForIsin</c> analysis, so the caller (FinlyticEngine) can record why the
/// asset was being watched in the first place. Returns <see langword="null"/> if the ISIN is not currently
/// in the universe (e.g. a manual "Analyze now" call for an asset nobody favorited/discovered/spiked).
/// </summary>
Task<MonitoredUniverseEntry?> GetEntryAsync(string isin, CancellationToken cancellationToken = default);
Task RefreshFavoritesAsync(CancellationToken cancellationToken = default);
Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 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
/// <c>fta_monitored_universe_assets</c> 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 <c>Program.cs</c>) - it is fully rebuilt within minutes from
/// <see cref="RefreshFavoritesAsync"/>/<see cref="RefreshDiscoveryAsync"/> and fresh sentiment-spike events, so
/// nothing of value would survive a restart anyway.
/// </summary>
public class TechnicalUniverseManager : ITechnicalUniverseManager
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ITAMqttRpcClient _rpcClient;
private readonly IFinlyticLogger<TechnicalUniverseManager> _logger;
public TechnicalUniverseManager(
IServiceScopeFactory scopeFactory,
ITAMqttRpcClient rpcClient,
IFinlyticLogger<TechnicalUniverseManager> 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<TechnicalAnalysisDbContext>();
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<TechnicalAnalysisDbContext>();
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<IReadOnlyList<MonitoredUniverseEntry>> GetActiveUniverseAsync(CancellationToken cancellationToken = default)
{
await RemoveExpiredAsync(cancellationToken);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var entities = await db.MonitoredUniverseAssets
.AsNoTracking()
.OrderBy(e => e.Priority)
.ThenByDescending(e => e.AddedAtUtc)
.ToListAsync(cancellationToken);
return entities.Select(ToEntry).ToList();
}
public async Task<MonitoredUniverseEntry?> 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<TechnicalAnalysisDbContext>();
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<List<string>, 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<List<AssetDto>, 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<string> ToCleanIsinSet(IEnumerable<string>? isins)
{
return new HashSet<string>(
(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<UniverseSource>(e.Source, out var src) ? src : UniverseSource.Discovery,
e.AddedAtUtc, e.ExpiresAtUtc, e.Priority
);
/// <summary>
/// Upserts every ISIN in <paramref name="freshIsins"/> under <paramref name="source"/>/<paramref name="priority"/>
/// in a single batch, and removes rows still tagged with <paramref name="source"/> whose ISIN is no longer
/// present in <paramref name="freshIsins"/> - 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.
/// </summary>
private async Task<int> UpsertSourceBatchAsync(UniverseSource source, int priority, HashSet<string> freshIsins, CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
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;
}
}