284 lines
12 KiB
C#
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;
|
|
}
|
|
}
|