feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user