feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies

This commit is contained in:
2026-08-24 21:36:05 +02:00
parent 12e7b57b16
commit f43ce2b7e9
36 changed files with 6792 additions and 0 deletions
@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace FinlyticTechnicals.Services;
public interface ITAMqttRpcClient
{
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
string channel,
TRequest requestData,
TimeSpan? timeout = null)
where TResponse : class
where TRequest : class;
Task PublishAsync<T>(string topic, T data, bool retain = false);
}
@@ -0,0 +1,245 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticTechnicals.Timeframe;
using FinlyticTechnicals.Util;
namespace FinlyticTechnicals.Services;
public interface IMultiTimeframeCandleAggregator
{
/// <summary>
/// Initializes historical ring buffers for an ISIN with Yahoo/database candles.
/// </summary>
void InitializeHistory(string isin, string timeframe, IEnumerable<CandleDto> candles);
/// <summary>
/// Processes an incoming clean tick and updates 1m, 5m, 15m, 1h, and 1d candles.
/// </summary>
void ProcessTick(CleanLiveTick tick);
/// <summary>
/// Gets a snapshot of the ring buffer for an ISIN and timeframe.
/// </summary>
IReadOnlyList<CandleDto> GetCandles(string isin, string timeframe);
/// <summary>
/// Gets all multi-timeframe candles (1m, 5m, 15m, 1h, 1d) as a dictionary.
/// </summary>
Dictionary<string, IReadOnlyList<CandleDto>> GetAllTimeframes(string isin);
/// <summary>
/// Event triggered when a timeframe bar completes.
/// </summary>
event Action<string, string, CandleDto>? OnCandleClosed;
}
public class MultiTimeframeCandleAggregator : IMultiTimeframeCandleAggregator
{
private readonly IFinlyticLogger<MultiTimeframeCandleAggregator> _logger;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>>> _buffers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CandleDto> _current1mCandles = new(StringComparer.OrdinalIgnoreCase);
private readonly object _aggregationLock = new();
public event Action<string, string, CandleDto>? OnCandleClosed;
public MultiTimeframeCandleAggregator(IFinlyticLogger<MultiTimeframeCandleAggregator> logger)
{
_logger = logger;
}
public void InitializeHistory(string isin, string timeframe, IEnumerable<CandleDto> candles)
{
if (string.IsNullOrWhiteSpace(isin) || string.IsNullOrWhiteSpace(timeframe)) return;
var cleanIsin = isin.Trim().ToUpperInvariant();
var cleanTf = timeframe.Trim().ToLowerInvariant();
var isinBuffers = _buffers.GetOrAdd(cleanIsin, _ => new ConcurrentDictionary<string, CircularRingBuffer<CandleDto>>(StringComparer.OrdinalIgnoreCase));
var ringBuffer = isinBuffers.GetOrAdd(cleanTf, _ => new CircularRingBuffer<CandleDto>(500));
var ordered = candles
.Where(c => c.Close > 0m)
.OrderBy(c => c.Timestamp)
.ToList();
ringBuffer.LoadBulk(ordered);
}
public void ProcessTick(CleanLiveTick tick)
{
if (tick == null || string.IsNullOrWhiteSpace(tick.Isin)) return;
var isin = tick.Isin.Trim().ToUpperInvariant();
var tickTime = tick.TimestampUtc;
var minuteBoundary = new DateTime(tickTime.Year, tickTime.Month, tickTime.Day, tickTime.Hour, tickTime.Minute, 0, DateTimeKind.Utc);
lock (_aggregationLock)
{
var isinBuffers = _buffers.GetOrAdd(isin, _ => new ConcurrentDictionary<string, CircularRingBuffer<CandleDto>>(StringComparer.OrdinalIgnoreCase));
var ringBuffer1m = isinBuffers.GetOrAdd("1m", _ => new CircularRingBuffer<CandleDto>(500));
if (_current1mCandles.TryGetValue(isin, out var current1m))
{
if (current1m.Timestamp == minuteBoundary)
{
// Update current open 1m bar
var updated = current1m with
{
High = Math.Max(current1m.High, tick.MidPrice),
Low = Math.Min(current1m.Low, tick.MidPrice),
Close = tick.MidPrice,
Volume = current1m.Volume + 1,
Bid = tick.Bid,
Ask = tick.Ask
};
_current1mCandles[isin] = updated;
ringBuffer1m.UpdateLast(updated);
}
else if (minuteBoundary > current1m.Timestamp)
{
// 1. Close current 1m bar
ringBuffer1m.UpdateLast(current1m);
OnCandleClosed?.Invoke(isin, "1m", current1m);
// 2. Reconnect-Lückenbehandlung (Gap Handling)
// If multiple minutes passed without ticks (e.g. disconnect), fill gaps flatly with Volume = 0
var gapStart = current1m.Timestamp.AddMinutes(1);
var lastClose = current1m.Close;
while (gapStart < minuteBoundary)
{
var flatBar = new CandleDto(
Timestamp: gapStart,
Open: lastClose,
High: lastClose,
Low: lastClose,
Close: lastClose,
Volume: 0,
Bid: tick.Bid,
Ask: tick.Ask
);
ringBuffer1m.Add(flatBar);
OnCandleClosed?.Invoke(isin, "1m", flatBar);
gapStart = gapStart.AddMinutes(1);
}
// 3. Start new 1m bar
var new1m = new CandleDto(
Timestamp: minuteBoundary,
Open: tick.MidPrice,
High: tick.MidPrice,
Low: tick.MidPrice,
Close: tick.MidPrice,
Volume: 1,
Bid: tick.Bid,
Ask: tick.Ask
);
_current1mCandles[isin] = new1m;
ringBuffer1m.Add(new1m);
// 4. Update higher timeframes (5m, 15m, 1h, 1d)
RebuildHigherTimeframes(isin, isinBuffers, ringBuffer1m);
}
}
else
{
// First tick for this ISIN
var new1m = new CandleDto(
Timestamp: minuteBoundary,
Open: tick.MidPrice,
High: tick.MidPrice,
Low: tick.MidPrice,
Close: tick.MidPrice,
Volume: 1,
Bid: tick.Bid,
Ask: tick.Ask
);
_current1mCandles[isin] = new1m;
ringBuffer1m.Add(new1m);
}
}
}
private void RebuildHigherTimeframes(string isin, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>> isinBuffers, CircularRingBuffer<CandleDto> ringBuffer1m)
{
var snapshot1m = ringBuffer1m.ToArray();
if (snapshot1m.Length == 0) return;
// Build 5m candles
AggregatePeriod(isin, isinBuffers, snapshot1m, "5m", 5);
// Build 15m candles
AggregatePeriod(isin, isinBuffers, snapshot1m, "15m", 15);
// Build 1h candles
AggregatePeriod(isin, isinBuffers, snapshot1m, "1h", 60);
// Build 1d candles
AggregateDaily(isin, isinBuffers, snapshot1m);
}
private void AggregatePeriod(string isin, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>> isinBuffers, CandleDto[] candles1m, string tfName, int minutes)
{
var targetBuffer = isinBuffers.GetOrAdd(tfName, _ => new CircularRingBuffer<CandleDto>(500));
targetBuffer.LoadBulk(FinlyticTechnicals.Indicators.CandleResampler.Resample(candles1m, minutes));
}
private void AggregateDaily(string isin, ConcurrentDictionary<string, CircularRingBuffer<CandleDto>> isinBuffers, CandleDto[] candles1m)
{
var targetBuffer = isinBuffers.GetOrAdd("1d", _ => new CircularRingBuffer<CandleDto>(500));
var aggregated = FinlyticTechnicals.Indicators.CandleResampler.Resample(candles1m, 1440);
// If daily buffer already has deep Yahoo history, stitch today's aggregated bar onto the end
if (targetBuffer.Count > 0 && aggregated.Count > 0)
{
var today = aggregated.Last();
var lastHistory = targetBuffer.GetLast();
if (lastHistory != null && lastHistory.Timestamp.Date == today.Timestamp.Date)
{
targetBuffer.UpdateLast(today);
}
else
{
targetBuffer.Add(today);
}
}
else if (aggregated.Count > 0)
{
targetBuffer.LoadBulk(aggregated);
}
}
public IReadOnlyList<CandleDto> GetCandles(string isin, string timeframe)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
var cleanTf = (timeframe ?? "15m").Trim().ToLowerInvariant();
if (_buffers.TryGetValue(cleanIsin, out var isinBuffers) &&
isinBuffers.TryGetValue(cleanTf, out var ringBuffer))
{
return ringBuffer.ToArray();
}
return [];
}
public Dictionary<string, IReadOnlyList<CandleDto>> GetAllTimeframes(string isin)
{
var result = new Dictionary<string, IReadOnlyList<CandleDto>>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(isin)) return result;
var cleanIsin = isin.Trim().ToUpperInvariant();
if (_buffers.TryGetValue(cleanIsin, out var isinBuffers))
{
foreach (var kvp in isinBuffers)
{
result[kvp.Key] = kvp.Value.ToArray();
}
}
return result;
}
}
@@ -0,0 +1,118 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Services;
using FinlyticTechnicals.Util;
using Microsoft.Extensions.Hosting;
namespace FinlyticTechnicals.Services;
public class TechnicalScannerBackgroundService : BackgroundService
{
private readonly ITechnicalUniverseManager _universeManager;
private readonly ITechnicalScoringEngine _scoringEngine;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<TechnicalScannerBackgroundService> _logger;
public TechnicalScannerBackgroundService(
ITechnicalUniverseManager universeManager,
ITechnicalScoringEngine scoringEngine,
ISettingsService settingsService,
IFinlyticLogger<TechnicalScannerBackgroundService> logger)
{
_universeManager = universeManager;
_scoringEngine = scoringEngine;
_settingsService = settingsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
"[TechnicalScanner] Starting Technical Universe Scanner Background Service...");
// Initial delay for MQTT connections to stabilize
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
// Initial synchronization of Universe
await _universeManager.RefreshFavoritesAsync(stoppingToken);
await _universeManager.RefreshDiscoveryAsync(stoppingToken);
DateTime lastFavoritesSyncUtc = DateTime.UtcNow;
DateTime lastDiscoverySyncUtc = DateTime.UtcNow;
while (!stoppingToken.IsCancellationRequested)
{
try
{
DateTime now = DateTime.UtcNow;
// 1. Check Periodic Sync Timers
if (now - lastFavoritesSyncUtc >= TimeSpan.FromMinutes(15))
{
await _universeManager.RefreshFavoritesAsync(stoppingToken);
lastFavoritesSyncUtc = DateTime.UtcNow;
}
if (now - lastDiscoverySyncUtc >= TimeSpan.FromMinutes(30))
{
await _universeManager.RefreshDiscoveryAsync(stoppingToken);
lastDiscoverySyncUtc = DateTime.UtcNow;
}
// 2. Retrieve Active Prioritized Scan Universe
var universe = await _universeManager.GetActiveUniverseAsync(stoppingToken);
if (universe.Count > 0)
{
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
"[TechnicalScanner] Scanning {Count} assets in active universe across all strategies...", universe.Count);
foreach (var entry in universe)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
var setups = await _scoringEngine.AnalyzeIsinAsync(entry.Isin, entry.Symbol, entry.Source, entry.AddedAtUtc, stoppingToken);
if (setups.Count > 0)
{
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
"[TechnicalScanner] Found {Count} active setup(s) for ISIN {Isin} (Top Score: {Score:F1})",
setups.Count, entry.Isin, setups[0].QualityScore);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScanner] Error analyzing ISIN {Isin}", entry.Isin);
}
// Gentle throttle between asset analysis runs
await Task.Delay(250, stoppingToken);
}
}
else
{
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
"[TechnicalScanner] Scan universe is currently empty. Waiting for next cycle.");
}
// Wait 60 seconds before next full universe evaluation pass
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScanner] Unexpected error in scanner loop. Retrying in 30 seconds.");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel,
"[TechnicalScanner] Technical Universe Scanner Background Service stopped.");
}
}
@@ -0,0 +1,580 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticTechnicals.Database;
using FinlyticTechnicals.Entities;
using FinlyticTechnicals.Indicators;
using FinlyticTechnicals.Patterns;
using FinlyticTechnicals.Strategies;
using FinlyticTechnicals.Util;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticTechnicals.Services;
public interface ITechnicalScoringEngine
{
/// <summary>
/// Evaluates technical setup, indicators, and patterns for an ISIN and returns trading setups.
/// </summary>
/// <param name="universeSource">
/// Which universe-selection mechanism this ISIN is currently monitored under (favorite/discovery/
/// sentiment-spike), if known - passed through onto the returned <see cref="StrategyResultDto"/>s and
/// persisted alongside them so downstream consumers (FinlyticEngine) can record why the asset was being
/// watched. <see langword="null"/> for an ad hoc analysis outside the scan universe.
/// </param>
/// <param name="universeEnteredAtUtc">When the ISIN entered that universe, alongside <paramref name="universeSource"/>.</param>
Task<List<StrategyResultDto>> AnalyzeIsinAsync(string isin, string? symbol = null, UniverseSource? universeSource = null, DateTime? universeEnteredAtUtc = null, CancellationToken cancellationToken = default);
/// <summary>
/// Gets full technical analysis including candles, calculated indicators, patterns, and signals for an ISIN.
/// </summary>
Task<TechnicalAnalysisDto?> GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active top-pick setups from the database.
/// </summary>
Task<List<StrategyResultDto>> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the last <paramref name="limit"/> setups persisted for <paramref name="isin"/> across all scan
/// cycles, most recent first - regardless of <c>IsActive</c>/expiry/top-pick status, so a caller can see
/// the raw quality-score trend over time, including setups too weak to ever have reached the engine.
/// </summary>
Task<List<StrategyResultDto>> GetRecentSetupHistoryAsync(string isin, int limit = 8, CancellationToken cancellationToken = default);
}
public class TechnicalScoringEngine : ITechnicalScoringEngine
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMultiTimeframeCandleAggregator _aggregator;
private readonly IYahooMarketDataScraper _yahooScraper;
private readonly IEnumerable<IPatternDetector> _patternDetectors;
private readonly IEnumerable<ITechnicalStrategy> _strategies;
private readonly IFinlyticLogger<TechnicalScoringEngine> _logger;
public TechnicalScoringEngine(
IServiceScopeFactory scopeFactory,
IMultiTimeframeCandleAggregator aggregator,
IYahooMarketDataScraper yahooScraper,
IEnumerable<IPatternDetector> patternDetectors,
IEnumerable<ITechnicalStrategy> strategies,
IFinlyticLogger<TechnicalScoringEngine> logger)
{
_scopeFactory = scopeFactory;
_aggregator = aggregator;
_yahooScraper = yahooScraper;
_patternDetectors = patternDetectors;
_strategies = strategies;
_logger = logger;
}
public async Task<List<StrategyResultDto>> AnalyzeIsinAsync(string isin, string? symbol = null, UniverseSource? universeSource = null, DateTime? universeEnteredAtUtc = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Resolve ticker symbol if needed
string targetSymbol = symbol ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetSymbol))
{
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
// 2. Ensure historical multi-timeframe candles are available in ring buffers
var candles15m = _aggregator.GetCandles(cleanIsin, "15m");
var candles1h = _aggregator.GetCandles(cleanIsin, "1h");
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
if (candles1d.Count < 20 || candles15m.Count < 10)
{
// Backfill deep history from Yahoo
var dailyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "1y", "1d", cancellationToken);
if (dailyRes.Count > 0)
{
var dailyDtos = dailyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "1d", dailyDtos);
}
var hourlyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "60d", "1h", cancellationToken);
if (hourlyRes.Count > 0)
{
var hourlyDtos = hourlyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "1h", hourlyDtos);
}
var min15Res = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "10d", "15m", cancellationToken);
if (min15Res.Count > 0)
{
var min15Dtos = min15Res.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "15m", min15Dtos);
}
}
var allTimeframes = _aggregator.GetAllTimeframes(cleanIsin);
var primaryCandles = _aggregator.GetCandles(cleanIsin, "15m");
if (primaryCandles.Count == 0)
{
primaryCandles = _aggregator.GetCandles(cleanIsin, "1d");
}
if (primaryCandles.Count < 5)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalScoringEngine] Insufficient candles for ISIN {Isin}", cleanIsin);
return [];
}
var lastCandle = primaryCandles.Last();
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
// Determine Market Regime
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
if (adx.IsTrending)
{
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
}
else if (currentAtr > (lastCandle.Close * 0.03m))
{
regime = MarketRegime.HighVolatilityChoppy;
}
// Build TechnicalContext
var indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
["ATR_14"] = currentAtr,
["ADX_14"] = adx.Adx,
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
};
var context = new TechnicalContext
{
Isin = cleanIsin,
Symbol = targetSymbol,
Timeframe = "15m",
TimestampUtc = lastCandle.Timestamp,
CurrentPrice = lastCandle.Close,
CurrentSpread = 0m,
IsSpreadVolatile = false,
CurrentAtr = currentAtr,
Regime = regime,
MultiTimeframeCandles = allTimeframes,
Indicators = indicators
};
// 3. Run all Pattern Detectors
var detectedPatterns = new List<PatternResultDto>();
foreach (var detector in _patternDetectors)
{
try
{
var pat = detector.Evaluate(context);
if (pat != null)
{
detectedPatterns.Add(pat);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalScoringEngine] Pattern detector {Detector} threw an exception for ISIN {Isin}", detector.GetType().Name, cleanIsin);
}
}
// 4. Run all Strategies
var evaluatedSetups = new List<StrategyResultDto>();
foreach (var strategy in _strategies.OrderBy(s => s.Priority))
{
try
{
if (!strategy.IsApplicable(regime)) continue;
var setup = strategy.Evaluate(context, detectedPatterns);
if (setup != null)
{
// Confluence Scoring Calculation:
// FinalScore = 0.35 * S_ind + 0.35 * S_pattern + 0.30 * S_strat
decimal indicatorScore = CalculateIndicatorConfluenceScore(indicators, setup.Direction);
decimal patternScore = detectedPatterns.Count > 0 ? detectedPatterns.Average(p => p.QualityScore) : 50m;
decimal strategyBaseScore = setup.QualityScore;
decimal finalScore = (0.35m * indicatorScore) + (0.35m * patternScore) + (0.30m * strategyBaseScore);
finalScore = Math.Clamp(finalScore, 0m, 100m);
bool isTopPick = finalScore >= 75.0m;
string rating = finalScore >= 85.0m ? "A+" :
finalScore >= 75.0m ? "A" :
finalScore >= 60.0m ? "B" : "C";
var scoredSetup = setup with
{
QualityScore = finalScore,
IsTopPick = isTopPick,
Rating = rating,
UniverseSource = universeSource,
UniverseEnteredAtUtc = universeEnteredAtUtc,
Regime = regime
};
evaluatedSetups.Add(scoredSetup);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalScoringEngine] Strategy {Strategy} threw an exception for ISIN {Isin}", strategy.StrategyKey, cleanIsin);
}
}
// 5. Persist Setups and Patterns into PostgreSQL
await PersistResultsAsync(cleanIsin, targetSymbol, detectedPatterns, evaluatedSetups);
return evaluatedSetups;
}
private decimal CalculateIndicatorConfluenceScore(Dictionary<string, decimal> ind, SignalDirection dir)
{
decimal score = 50m;
if (dir == SignalDirection.Buy)
{
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > e50) score += 15m;
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m;
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
if (ind.TryGetValue("VWAP", out var vwap) && ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap) score += 10m;
}
else if (dir == SignalDirection.Sell)
{
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 < e50) score += 15m;
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m;
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
}
return Math.Clamp(score, 0m, 100m);
}
private async Task PersistResultsAsync(string isin, string symbol, List<PatternResultDto> patterns, List<StrategyResultDto> setups)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
// Save detected patterns
foreach (var pat in patterns)
{
db.FtaDetectedPatterns.Add(new FtaDetectedPatternEntity
{
Id = pat.Id,
Isin = isin,
Timeframe = pat.Timeframe,
PatternType = pat.Type.ToString(),
Category = pat.Category.ToString(),
Bias = pat.Bias.ToString(),
Name = pat.Name,
KeyPriceLevel = pat.KeyPriceLevel,
UpperBoundary = pat.UpperBoundary,
LowerBoundary = pat.LowerBoundary,
InvalidationLevel = pat.InvalidationLevel,
QualityScore = pat.QualityScore,
Description = pat.Description,
ExtraData = pat.ExtraData,
DetectedAtUtc = pat.DetectedAt
});
}
// Save strategy setups
foreach (var setup in setups)
{
db.FtaTechnicalSetups.Add(new FtaTechnicalSetupEntity
{
SetupId = setup.SetupId,
Isin = isin,
Symbol = symbol,
Timeframe = setup.Timeframe,
StrategyKey = setup.StrategyKey,
StrategyName = setup.StrategyName,
Direction = setup.Direction.ToString(),
QualityScore = setup.QualityScore,
CurrentPrice = setup.CurrentPrice,
EntryPrice = setup.EntryPrice,
InvalidationPrice = setup.InvalidationPrice,
CurrentAtr = setup.CurrentAtr,
EstimatedRiskRewardRatio = setup.EstimatedRiskRewardRatio,
ExitPlan = setup.ExitPlan,
TechnicalRationale = setup.TechnicalRationale,
TriggeringPatterns = setup.TriggeringPatterns,
IndicatorSnapshot = setup.IndicatorSnapshot,
IsTopPick = setup.IsTopPick,
Rating = setup.Rating,
IsActive = true,
CreatedAtUtc = setup.CreatedAt,
ExpiresAtUtc = setup.ExpiresAt,
UniverseSource = setup.UniverseSource?.ToString(),
UniverseEnteredAtUtc = setup.UniverseEnteredAtUtc,
Regime = setup.Regime?.ToString()
});
}
await db.SaveChangesAsync();
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalScoringEngine] Error persisting patterns & setups for ISIN {Isin}", isin);
}
}
public async Task<List<StrategyResultDto>> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var now = DateTime.UtcNow;
var query = db.FtaTechnicalSetups
.AsNoTracking()
.Where(s => s.IsActive && s.ExpiresAtUtc > now);
if (topPicksOnly)
{
query = query.Where(s => s.IsTopPick);
}
if (minScore.HasValue && minScore.Value > 0)
{
query = query.Where(s => s.QualityScore >= minScore.Value);
}
var entities = await query
.OrderByDescending(s => s.QualityScore)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(e => new StrategyResultDto(
SetupId: e.SetupId,
Isin: e.Isin,
Symbol: e.Symbol,
Timeframe: e.Timeframe,
StrategyKey: e.StrategyKey,
StrategyName: e.StrategyName,
Direction: Enum.TryParse<SignalDirection>(e.Direction, out var dir) ? dir : SignalDirection.Buy,
QualityScore: e.QualityScore,
CurrentPrice: e.CurrentPrice,
EntryPrice: e.EntryPrice,
InvalidationPrice: e.InvalidationPrice,
CurrentAtr: e.CurrentAtr,
EstimatedRiskRewardRatio: e.EstimatedRiskRewardRatio,
ExitPlan: e.ExitPlan,
TechnicalRationale: e.TechnicalRationale,
TriggeringPatterns: e.TriggeringPatterns ?? [],
IndicatorSnapshot: e.IndicatorSnapshot ?? [],
CreatedAt: e.CreatedAtUtc,
ExpiresAt: e.ExpiresAtUtc,
IsTopPick: e.IsTopPick,
Rating: e.Rating,
UniverseSource: Enum.TryParse<UniverseSource>(e.UniverseSource, out var universeSource) ? universeSource : null,
UniverseEnteredAtUtc: e.UniverseEnteredAtUtc,
Regime: Enum.TryParse<MarketRegime>(e.Regime, out var regimeParsed) ? regimeParsed : null
)).ToList();
}
public async Task<List<StrategyResultDto>> GetRecentSetupHistoryAsync(string isin, int limit = 8, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var entities = await db.FtaTechnicalSetups
.AsNoTracking()
.Where(s => s.Isin == cleanIsin)
.OrderByDescending(s => s.CreatedAtUtc)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(e => new StrategyResultDto(
SetupId: e.SetupId,
Isin: e.Isin,
Symbol: e.Symbol,
Timeframe: e.Timeframe,
StrategyKey: e.StrategyKey,
StrategyName: e.StrategyName,
Direction: Enum.TryParse<SignalDirection>(e.Direction, out var dir) ? dir : SignalDirection.Buy,
QualityScore: e.QualityScore,
CurrentPrice: e.CurrentPrice,
EntryPrice: e.EntryPrice,
InvalidationPrice: e.InvalidationPrice,
CurrentAtr: e.CurrentAtr,
EstimatedRiskRewardRatio: e.EstimatedRiskRewardRatio,
ExitPlan: e.ExitPlan,
TechnicalRationale: e.TechnicalRationale,
TriggeringPatterns: e.TriggeringPatterns ?? [],
IndicatorSnapshot: e.IndicatorSnapshot ?? [],
CreatedAt: e.CreatedAtUtc,
ExpiresAt: e.ExpiresAtUtc,
IsTopPick: e.IsTopPick,
Rating: e.Rating,
UniverseSource: Enum.TryParse<UniverseSource>(e.UniverseSource, out var universeSource) ? universeSource : null,
UniverseEnteredAtUtc: e.UniverseEnteredAtUtc,
Regime: Enum.TryParse<MarketRegime>(e.Regime, out var regimeParsed) ? regimeParsed : null
)).ToList();
}
public async Task<TechnicalAnalysisDto?> GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Resolve ticker symbol if needed
string targetSymbol = symbol ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetSymbol))
{
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
// 2. Ensure historical multi-timeframe candles & setups are calculated
var evaluatedSetups = await AnalyzeIsinAsync(cleanIsin, targetSymbol, cancellationToken: cancellationToken);
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
var primaryCandles = candles1d.Count > 0 ? candles1d : _aggregator.GetCandles(cleanIsin, "15m");
if (primaryCandles.Count == 0)
{
primaryCandles = _aggregator.GetCandles(cleanIsin, "1h");
}
if (primaryCandles.Count == 0)
{
return null;
}
var lastCandle = primaryCandles.Last();
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
if (adx.IsTrending)
{
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
}
else if (currentAtr > (lastCandle.Close * 0.03m))
{
regime = MarketRegime.HighVolatilityChoppy;
}
var context = new TechnicalContext
{
Isin = cleanIsin,
Symbol = targetSymbol,
Timeframe = "1d",
TimestampUtc = lastCandle.Timestamp,
CurrentPrice = lastCandle.Close,
CurrentSpread = 0m,
IsSpreadVolatile = false,
CurrentAtr = currentAtr,
Regime = regime,
MultiTimeframeCandles = _aggregator.GetAllTimeframes(cleanIsin),
Indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
["ATR_14"] = currentAtr,
["ADX_14"] = adx.Adx,
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
}
};
var detectedPatterns = new List<PatternResultDto>();
foreach (var detector in _patternDetectors)
{
try
{
var pat = detector.Evaluate(context);
if (pat != null)
{
detectedPatterns.Add(pat);
}
}
catch { }
}
var indicatorList = new List<IndicatorValuesDto>();
var candlesList = primaryCandles.ToList();
for (int i = 0; i < candlesList.Count; i++)
{
var slice = candlesList.Take(i + 1).ToList();
var c = candlesList[i];
var macd = TechnicalIndicatorsEngine.CalculateMacd(slice);
var st = TechnicalIndicatorsEngine.CalculateSuperTrend(slice);
var atr = TechnicalIndicatorsEngine.CalculateAtr(slice, 14);
indicatorList.Add(new IndicatorValuesDto(
Timestamp: c.Timestamp,
Ema20: TechnicalIndicatorsEngine.CalculateEma(slice, 20),
Sma50: TechnicalIndicatorsEngine.CalculateSma(slice, 50),
Sma200: TechnicalIndicatorsEngine.CalculateSma(slice, 200),
Rsi14: TechnicalIndicatorsEngine.CalculateRsi(slice, 14),
MacdLine: macd.MacdLine,
MacdSignal: macd.SignalLine,
MacdHistogram: macd.Histogram,
Atr14: atr,
Vwap: TechnicalIndicatorsEngine.CalculateVwap(slice),
SupertrendUpper: st.Direction == SignalDirection.Sell ? st.Value : null,
SupertrendLower: st.Direction == SignalDirection.Buy ? st.Value : null,
SupertrendDirection: st.Direction.ToString().ToUpperInvariant(),
RecommendedStopLoss: c.Close - (atr * 2m)
));
}
var chartPatterns = detectedPatterns.Select(p => new ChartPatternDto(
Type: p.Type.ToString(),
Description: p.Description,
UpperLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High), new(lastCandle.Timestamp, p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High) },
LowerLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low), new(lastCandle.Timestamp, p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low) },
ApexTime: lastCandle.Timestamp,
BreakoutSignal: new BreakoutSignalDto(lastCandle.Timestamp, p.Bias.ToString().ToUpperInvariant(), p.KeyPriceLevel > 0m ? p.KeyPriceLevel : lastCandle.Close, p.KeyPriceLevel > 0m ? p.KeyPriceLevel * 1.05m : lastCandle.Close * 1.05m, 5.0m),
ConfidencePercent: p.QualityScore
)).ToList();
var strategySignals = evaluatedSetups.Select(s => new StrategySignalDto(
Type: s.StrategyKey,
Timestamp: s.CreatedAt,
Direction: s.Direction.ToString().ToUpperInvariant(),
Price: s.CurrentPrice,
Description: s.TechnicalRationale
)).ToList();
var marketRegimeDto = new MarketRegimeDto(
VixValue: 18.5m,
VixRegime: regime.ToString(),
MarketTrend: regime == MarketRegime.BullishTrending ? "Bullish" : regime == MarketRegime.BearishTrending ? "Bearish" : "Neutral",
DxyValue: 104.2m,
DxyState: "Neutral",
SummaryText: $"Market Regime: {regime} with ATR {currentAtr:F2}"
);
return new TechnicalAnalysisDto(
Isin: cleanIsin,
Ticker: targetSymbol,
CompanyName: targetSymbol,
LastUpdated: lastCandle.Timestamp,
Candles: candlesList,
Indicators: indicatorList,
Patterns: chartPatterns,
Signals: strategySignals,
MarketRegime: marketRegimeDto,
Currency: "EUR"
);
}
}
@@ -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;
}
}
@@ -0,0 +1,148 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticTechnicals.Util;
namespace FinlyticTechnicals.Services;
/// <summary>
/// Cleaned, normalized real-time tick ready for multi-timeframe aggregation.
/// </summary>
public record CleanLiveTick(
string Isin,
decimal MidPrice,
decimal Bid,
decimal Ask,
decimal LastPrice,
decimal SpreadPercent,
bool IsSpreadVolatile,
DateTime TimestampUtc
);
public interface ITradeRepublicIngestionService
{
/// <summary>
/// Event triggered when a cleaned, UTC-normalized tick arrives.
/// </summary>
event Func<CleanLiveTick, Task>? OnTickReceived;
/// <summary>
/// Processes a raw tick from Trade Republic (e.g. via WebSocket or Poller).
/// </summary>
Task<CleanLiveTick?> ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default);
}
public class TradeRepublicIngestionService : ITradeRepublicIngestionService
{
private readonly IFinlyticLogger<TradeRepublicIngestionService> _finlyticLogger;
private static readonly TimeZoneInfo BerlinTimeZone = GetBerlinTimeZone();
public event Func<CleanLiveTick, Task>? OnTickReceived;
public TradeRepublicIngestionService(IFinlyticLogger<TradeRepublicIngestionService> finlyticLogger)
{
_finlyticLogger = finlyticLogger;
}
/// <summary>
/// Processes a raw incoming tick with strict UTC normalization, spread check, and mid-price calculation.
/// </summary>
public async Task<CleanLiveTick?> ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Strict UTC Normalization
DateTime utcTimestamp;
if (timestamp.HasValue)
{
var rawTime = timestamp.Value;
if (rawTime.Kind == DateTimeKind.Utc)
{
utcTimestamp = rawTime;
}
else if (rawTime.Kind == DateTimeKind.Unspecified)
{
// Trade Republic ticks typically arrive in German local market time (Europe/Berlin)
utcTimestamp = TimeZoneInfo.ConvertTimeToUtc(rawTime, BerlinTimeZone);
}
else
{
utcTimestamp = rawTime.ToUniversalTime();
}
}
else
{
utcTimestamp = DateTime.UtcNow;
}
// 2. Clean Mid-Price Calculation: (Bid + Ask) / 2
decimal cleanMidPrice;
if (bid > 0m && ask > 0m)
{
cleanMidPrice = (bid + ask) / 2m;
}
else if (last.HasValue && last.Value > 0m)
{
cleanMidPrice = last.Value;
if (bid <= 0m) bid = cleanMidPrice;
if (ask <= 0m) ask = cleanMidPrice;
}
else
{
return null; // Invalid quote
}
// 3. Spread Calculation & Volatility Tagging
decimal spreadPercent = 0m;
bool isSpreadVolatile = false;
if (cleanMidPrice > 0m && ask >= bid)
{
spreadPercent = ((ask - bid) / cleanMidPrice) * 100m;
if (spreadPercent > 1.5m)
{
isSpreadVolatile = true;
}
}
var cleanTick = new CleanLiveTick(
Isin: cleanIsin,
MidPrice: cleanMidPrice,
Bid: bid,
Ask: ask,
LastPrice: last ?? cleanMidPrice,
SpreadPercent: spreadPercent,
IsSpreadVolatile: isSpreadVolatile,
TimestampUtc: utcTimestamp
);
if (OnTickReceived != null)
{
await OnTickReceived.Invoke(cleanTick);
}
return cleanTick;
}
private static TimeZoneInfo GetBerlinTimeZone()
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time"); // Windows ID
}
catch
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // Linux IANA ID
}
catch
{
return TimeZoneInfo.Utc;
}
}
}
}
@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticCore.Services.Yahoo;
using FinlyticTechnicals.Util;
using Microsoft.Extensions.Configuration;
namespace FinlyticTechnicals.Services;
public record YahooCandlesResult(
List<CandleDto> Candles,
string Currency
);
public interface IYahooMarketDataScraper
{
/// <summary>
/// Resolves ticker from ISIN using Yahoo Search API.
/// </summary>
Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches historical candles with strict UTC timestamps.
/// </summary>
Task<List<CandleDto>> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default);
/// <summary>
/// Fetches historical candles with currency metadata.
/// </summary>
Task<YahooCandlesResult> FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default);
}
public class YahooMarketDataScraper : IYahooMarketDataScraper
{
private readonly YahooFinanceClient _yahooClient;
private readonly IConfiguration _configuration;
private readonly IFinlyticLogger<YahooMarketDataScraper> _finlyticLogger;
public YahooMarketDataScraper(
YahooFinanceClient yahooClient,
IConfiguration configuration,
IFinlyticLogger<YahooMarketDataScraper> finlyticLogger)
{
_yahooClient = yahooClient;
_configuration = configuration;
_finlyticLogger = finlyticLogger;
}
public async Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim().ToUpperInvariant();
if (cleanIsin.Contains('.'))
{
return cleanIsin;
}
if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
{
var (cryptoSubtitle, cryptoName) = await FinlyticCore.Util.CryptoSubtitleResolver.ResolveCryptoInfoAsync(
cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken);
if (!string.IsNullOrWhiteSpace(cryptoSubtitle))
{
var candidates = new[] { $"{cryptoSubtitle}-EUR", $"{cryptoSubtitle}-USD", cryptoSubtitle };
foreach (var candidate in candidates)
{
try
{
var res = await FetchHistoricalCandlesWithCurrencyAsync(candidate, "5d", "1d", cancellationToken);
if (res.Candles.Count > 0)
{
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", cleanIsin, candidate, cryptoSubtitle);
return candidate;
}
}
catch { }
}
return $"{cryptoSubtitle}-EUR";
}
}
try
{
var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken);
if (searchResult?.Quotes != null && searchResult.Quotes.Count > 0)
{
var prioritizedSuffixes = new[] { ".DE", ".F", ".SG", ".MU", ".BE", ".DU", ".HM" };
foreach (var suffix in prioritizedSuffixes)
{
var match = searchResult.Quotes.FirstOrDefault(q =>
!string.IsNullOrWhiteSpace(q.Symbol) &&
q.Symbol.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved ISIN {Isin} to German ticker {Symbol}", cleanIsin, match.Symbol);
return match.Symbol;
}
}
var defaultQuote = searchResult.Quotes.FirstOrDefault(q => !string.IsNullOrWhiteSpace(q.Symbol));
if (defaultQuote != null)
{
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved ISIN {Isin} to primary ticker {Symbol}", cleanIsin, defaultQuote.Symbol);
return defaultQuote.Symbol;
}
}
}
catch (Exception ex)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Search failed for ISIN {Isin}", cleanIsin);
}
return null;
}
public async Task<List<CandleDto>> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default)
{
var result = await FetchHistoricalCandlesWithCurrencyAsync(symbol, range, interval, cancellationToken);
return result.Candles;
}
public async Task<YahooCandlesResult> FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default)
{
var results = new List<CandleDto>();
string detectedCurrency = FallbackCurrencyBySymbol(symbol);
if (string.IsNullOrWhiteSpace(symbol)) return new YahooCandlesResult(results, detectedCurrency);
try
{
var chartDto = await _yahooClient.GetChartAsync(symbol, range, interval, cancellationToken);
var resultObj = chartDto?.Chart?.Result?.FirstOrDefault();
if (resultObj == null)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] No chart data returned from Yahoo Client for symbol {Symbol}", symbol);
return new YahooCandlesResult(results, detectedCurrency);
}
if (!string.IsNullOrWhiteSpace(resultObj.Meta?.Currency))
{
detectedCurrency = resultObj.Meta.Currency.ToUpperInvariant();
}
var timestamps = resultObj.Timestamp;
var quote = resultObj.Indicators?.Quote?.FirstOrDefault();
if (timestamps == null || quote == null || timestamps.Count == 0)
{
return new YahooCandlesResult(results, detectedCurrency);
}
var opens = quote.Open ?? [];
var highs = quote.High ?? [];
var lows = quote.Low ?? [];
var closes = quote.Close ?? [];
var volumes = quote.Volume ?? [];
for (int i = 0; i < timestamps.Count; i++)
{
// Strict UTC timestamp
var dt = DateTimeOffset.FromUnixTimeSeconds(timestamps[i]).UtcDateTime;
var open = i < opens.Count && opens[i].HasValue ? (decimal)opens[i]!.Value : 0m;
var high = i < highs.Count && highs[i].HasValue ? (decimal)highs[i]!.Value : open;
var low = i < lows.Count && lows[i].HasValue ? (decimal)lows[i]!.Value : open;
var close = i < closes.Count && closes[i].HasValue ? (decimal)closes[i]!.Value : open;
var vol = i < volumes.Count && volumes[i].HasValue ? (long)volumes[i]!.Value : 0L;
if (close <= 0m && open <= 0m) continue;
results.Add(new CandleDto(
Timestamp: dt,
Open: open,
High: Math.Max(high, Math.Max(open, close)),
Low: Math.Min(low, Math.Min(open, close)),
Close: close,
Volume: vol
));
}
await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Successfully fetched {Count} candles for {Symbol} ({Range}, {Interval}, Currency: {Currency})",
results.Count, symbol, range, interval, detectedCurrency);
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Error fetching historical candles for {Symbol}", symbol);
}
return new YahooCandlesResult(results, detectedCurrency);
}
private static string FallbackCurrencyBySymbol(string symbol)
{
if (string.IsNullOrWhiteSpace(symbol)) return "EUR";
var s = symbol.Trim().ToUpperInvariant();
if (s.EndsWith(".DE") || s.EndsWith(".F") || s.EndsWith(".PA") || s.EndsWith(".AS") || s.EndsWith(".MI"))
return "EUR";
if (s.EndsWith(".L"))
return "GBp";
return "USD";
}
}