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