using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Services.TradeRepublic; using FinlyticTechnicalAnalysis.Database; using FinlyticTechnicalAnalysis.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace FinlyticTechnicalAnalysis.Services; public interface ITechnicalAnalysisDbService { Task GetAnalysisAsync(string isin, bool forceRefresh = false, CancellationToken cancellationToken = default); Task GetLivePriceAsync(string isin, CancellationToken cancellationToken = default); } public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService { private readonly IServiceScopeFactory _scopeFactory; private readonly IYahooMarketDataScraper _yahooScraper; private readonly ITradeRepublicService _trService; private readonly ITechnicalAnalysisCalculator _calculator; private readonly ILogger _logger; // Cache Layer 1: In-Memory Candles Cache (TTL: 15 Minuten) private static readonly ConcurrentDictionary Candles, string Symbol, string Currency, DateTime FetchedAt)> _candleCache = new(); // Per-ISIN Semaphores zur Vermeidung von Cache-Stampedes private static readonly ConcurrentDictionary _perIsinLocks = new(); private static readonly TimeSpan CandleCacheTtl = TimeSpan.FromMinutes(15); private static readonly TimeSpan DbCacheTtl = TimeSpan.FromHours(1); public TechnicalAnalysisDbService( IServiceScopeFactory scopeFactory, IYahooMarketDataScraper yahooScraper, ITradeRepublicService trService, ITechnicalAnalysisCalculator calculator, ILogger logger) { _scopeFactory = scopeFactory; _yahooScraper = yahooScraper; _trService = trService; _calculator = calculator; _logger = logger; } public async Task GetAnalysisAsync(string isin, bool forceRefresh = false, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return null; var cleanIsin = isin.Trim().ToUpperInvariant(); // 1. Layer-1: Fast-Path aus In-Memory Cache (wenn kein forceRefresh) if (!forceRefresh && _candleCache.TryGetValue(cleanIsin, out var ramEntry) && DateTime.UtcNow - ramEntry.FetchedAt < CandleCacheTtl) { _logger.LogDebug("[{Channel}] RAM-Cache Hit for ISIN {Isin}. Merging live price...", "TechnicalAnalysisChannel", cleanIsin); return await BuildAnalysisWithLivePriceAsync(cleanIsin, ramEntry.Candles, ramEntry.Symbol, ramEntry.Currency, cancellationToken); } // Semaphor für ISIN holen (verhindert doppelte parallele Abfragen der gleichen ISIN) var semaphore = _perIsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1)); await semaphore.WaitAsync(cancellationToken); try { // Re-Check nach Lock-Erhalt if (!forceRefresh && _candleCache.TryGetValue(cleanIsin, out ramEntry) && DateTime.UtcNow - ramEntry.FetchedAt < CandleCacheTtl) { return await BuildAnalysisWithLivePriceAsync(cleanIsin, ramEntry.Candles, ramEntry.Symbol, ramEntry.Currency, cancellationToken); } // 2. Layer-2: Prüfen ob frische Daten in der Datenbank liegen if (!forceRefresh) { var dbDto = await GetFromDbCacheAsync(cleanIsin, cancellationToken); if (dbDto != null) { _logger.LogDebug("[{Channel}] DB-Cache Hit for ISIN {Isin}.", "TechnicalAnalysisChannel", cleanIsin); return dbDto; } } // 3. Cache Miss / ForceRefresh: Vollständige Neuberechnung return await FullRefreshAsync(cleanIsin, cancellationToken); } finally { semaphore.Release(); // Speicher aufräumen, falls Lock nicht mehr genutzt wird if (semaphore.CurrentCount == 1) { _perIsinLocks.TryRemove(cleanIsin, out _); } } } public async Task GetLivePriceAsync(string isin, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return null; var cleanIsin = isin.Trim().ToUpperInvariant(); var (livePrice, liveBid, liveAsk) = await FetchLivePriceAsync(cleanIsin, cancellationToken); if (!livePrice.HasValue) return null; return new LivePriceDto( cleanIsin, Math.Round(livePrice.Value, 2), 0m, // Percent change optional liveBid.HasValue ? Math.Round(liveBid.Value, 2) : null, liveAsk.HasValue ? Math.Round(liveAsk.Value, 2) : null ); } private async Task FullRefreshAsync(string cleanIsin, CancellationToken cancellationToken) { _logger.LogInformation("[{Channel}] Full refresh for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); var tickerTask = _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); var macroTask = FetchMacroDataAsync(cancellationToken); await Task.WhenAll(tickerTask, macroTask); var ticker = await tickerTask; var querySymbol = !string.IsNullOrEmpty(ticker) ? ticker : cleanIsin; var (vix, gspc, dxy) = await macroTask; var yahooResult = await _yahooScraper.FetchHistoricalCandlesWithCurrencyAsync(querySymbol, "1y", "1d", cancellationToken); var candles = yahooResult.Candles; var currency = yahooResult.Currency; if (candles.Count == 0 && querySymbol != cleanIsin) { yahooResult = await _yahooScraper.FetchHistoricalCandlesWithCurrencyAsync(cleanIsin, "1y", "1d", cancellationToken); candles = yahooResult.Candles; currency = yahooResult.Currency; } if (candles.Count == 0) { _logger.LogWarning("[{Channel}] No candles retrieved for {Symbol}", "TechnicalAnalysisChannel", querySymbol); return null; } // In RAM-Cache sichern _candleCache[cleanIsin] = (candles.Select(CloneCandle).ToList(), querySymbol, currency, DateTime.UtcNow); // Live-Preis einpflegen await MergeLivePriceAsync(cleanIsin, candles, querySymbol, cancellationToken); var resultDto = BuildDto(cleanIsin, querySymbol, currency, candles, vix, gspc, dxy); // Synchron und sicher in DB persistieren await PersistToDbCacheAsync(cleanIsin, querySymbol, resultDto, cancellationToken); return resultDto; } private async Task BuildAnalysisWithLivePriceAsync( string cleanIsin, List cachedCandles, string querySymbol, string currency, CancellationToken cancellationToken) { var candles = cachedCandles.Select(CloneCandle).ToList(); var livePriceTask = FetchLivePriceAsync(cleanIsin, cancellationToken); var macroTask = FetchMacroDataAsync(cancellationToken); await Task.WhenAll(livePriceTask, macroTask); var (livePrice, liveBid, liveAsk) = await livePriceTask; var (vix, gspc, dxy) = await macroTask; if (livePrice.HasValue && livePrice.Value > 0m) { var today = DateTime.UtcNow.Date; var lastCandle = candles.LastOrDefault(c => c.Timestamp.Date == today); if (lastCandle != null) { lastCandle.Close = livePrice.Value; lastCandle.High = Math.Max(lastCandle.High, livePrice.Value); lastCandle.Low = Math.Min(lastCandle.Low, livePrice.Value); if (liveBid.HasValue) lastCandle.Bid = liveBid.Value; if (liveAsk.HasValue) lastCandle.Ask = liveAsk.Value; } else { var prevClose = candles.LastOrDefault()?.Close ?? livePrice.Value; candles.Add(new MarketCandleEntity { Symbol = querySymbol, Interval = "1d", Timestamp = today, Open = prevClose, High = Math.Max(prevClose, livePrice.Value), Low = Math.Min(prevClose, livePrice.Value), Close = livePrice.Value, Volume = 1000, Bid = liveBid, Ask = liveAsk }); } } return BuildDto(cleanIsin, querySymbol, currency, candles, vix, gspc, dxy); } private async Task MergeLivePriceAsync(string cleanIsin, List candles, string querySymbol, CancellationToken cancellationToken) { var (livePrice, liveBid, liveAsk) = await FetchLivePriceAsync(cleanIsin, cancellationToken); if (!livePrice.HasValue || livePrice.Value <= 0m) return; var today = DateTime.UtcNow.Date; var lastCandle = candles.LastOrDefault(c => c.Timestamp.Date == today); if (lastCandle != null) { lastCandle.Close = livePrice.Value; lastCandle.High = Math.Max(lastCandle.High, livePrice.Value); lastCandle.Low = Math.Min(lastCandle.Low, livePrice.Value); if (liveBid.HasValue) lastCandle.Bid = liveBid.Value; if (liveAsk.HasValue) lastCandle.Ask = liveAsk.Value; } else { var prevClose = candles.LastOrDefault()?.Close ?? livePrice.Value; candles.Add(new MarketCandleEntity { Symbol = querySymbol, Interval = "1d", Timestamp = today, Open = prevClose, High = Math.Max(prevClose, livePrice.Value), Low = Math.Min(prevClose, livePrice.Value), Close = livePrice.Value, Volume = 1000, Bid = liveBid, Ask = liveAsk }); } } private async Task<(decimal? livePrice, decimal? liveBid, decimal? liveAsk)> FetchLivePriceAsync(string cleanIsin, CancellationToken cancellationToken) { decimal? livePrice = null; decimal? liveBid = null; decimal? liveAsk = null; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(1500); // Maximal 1.5 Sekunden Wartezeit auf Ticker var trTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int? subId = await _trService.SubscribeRealtimeTickerAsync(cleanIsin, tick => { if (tick.Last != null && tick.Last.PriceValue > 0m) { livePrice = tick.Last.PriceValue; liveBid = tick.Bid?.PriceValue; liveAsk = tick.Ask?.PriceValue; trTask.TrySetResult(true); } }, cts.Token); if (subId.HasValue) { try { await trTask.Task.WaitAsync(cts.Token); } catch (OperationCanceledException) { } await _trService.UnsubscribeRealtimeTickerAsync(subId.Value); } } catch (Exception ex) { _logger.LogWarning(ex, "[{Channel}] Real-time price fetch skipped for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); } return (livePrice, liveBid, liveAsk); } private async Task<(MacroDataEntity vix, MacroDataEntity gspc, MacroDataEntity dxy)> FetchMacroDataAsync(CancellationToken cancellationToken) { var vixTask = _yahooScraper.FetchMacroTickerAsync("^VIX", cancellationToken); var gspcTask = _yahooScraper.FetchMacroTickerAsync("^GSPC", cancellationToken); var dxyTask = _yahooScraper.FetchMacroTickerAsync("DX-Y.NY", cancellationToken); await Task.WhenAll(vixTask, gspcTask, dxyTask); var vix = await vixTask ?? new MacroDataEntity { Symbol = "^VIX", Value = 18.5m, TrendState = "Moderate" }; var gspc = await gspcTask ?? new MacroDataEntity { Symbol = "^GSPC", Value = 5500m, TrendState = "Bullish" }; var dxy = await dxyTask ?? new MacroDataEntity { Symbol = "DX-Y.NY", Value = 104.2m, TrendState = "Neutral" }; return (vix, gspc, dxy); } private TechnicalAnalysisDto BuildDto(string cleanIsin, string querySymbol, string currency, List candles, MacroDataEntity vix, MacroDataEntity gspc, MacroDataEntity dxy) { var vixRegime = vix.Value > 25m ? "HighVolatility" : (vix.Value > 18m ? "Moderate" : "LowVolatility"); var summaryText = $"Markt-Vola (VIX: {vix.Value:F1}) ist {vixRegime}. S&P 500 Trend ist {gspc.TrendState}. DXY: {dxy.Value:F1}."; var marketRegime = new MarketRegimeDto( VixValue: vix.Value, VixRegime: vixRegime, MarketTrend: gspc.TrendState, DxyValue: dxy.Value, DxyState: dxy.TrendState == "Bullish" ? "DollarStrengthening" : "DollarWeakening", SummaryText: summaryText); var (indicators, patterns, signals) = _calculator.CalculateAnalysis(candles, currency); var candleDtos = candles.Select(c => new CandleDto( Timestamp: c.Timestamp, Open: c.Open, High: c.High, Low: c.Low, Close: c.Close, Volume: c.Volume, Bid: c.Bid, Ask: c.Ask)).ToList(); return new TechnicalAnalysisDto( Isin: cleanIsin, Ticker: querySymbol, CompanyName: querySymbol, LastUpdated: DateTime.UtcNow, Candles: candleDtos, Indicators: indicators, Patterns: patterns, Signals: signals, MarketRegime: marketRegime, Currency: currency); } private async Task GetFromDbCacheAsync(string cleanIsin, CancellationToken cancellationToken) { try { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var cached = await db.CachedAnalyses .AsNoTracking() .FirstOrDefaultAsync(c => c.Isin == cleanIsin, cancellationToken); if (cached != null && DateTime.UtcNow - cached.CalculatedAt < DbCacheTtl) { return JsonSerializer.Deserialize(cached.AnalysisJson); } } catch (Exception ex) { _logger.LogWarning(ex, "[{Channel}] Failed to read DB cache for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); } return null; } private async Task PersistToDbCacheAsync(string cleanIsin, string querySymbol, TechnicalAnalysisDto dto, CancellationToken cancellationToken) { try { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var json = JsonSerializer.Serialize(dto); var existing = await db.CachedAnalyses.FirstOrDefaultAsync(c => c.Isin == cleanIsin, cancellationToken); if (existing != null) { existing.Ticker = querySymbol; existing.AnalysisJson = json; existing.CalculatedAt = DateTime.UtcNow; } else { db.CachedAnalyses.Add(new CachedAnalysisEntity { Isin = cleanIsin, Ticker = querySymbol, AnalysisJson = json, CalculatedAt = DateTime.UtcNow }); } await db.SaveChangesAsync(cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "[{Channel}] Failed to persist TA DB cache for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); } } private static MarketCandleEntity CloneCandle(MarketCandleEntity c) => new() { Symbol = c.Symbol, Interval = c.Interval, Timestamp = c.Timestamp, Open = c.Open, High = c.High, Low = c.Low, Close = c.Close, Volume = c.Volume, Bid = c.Bid, Ask = c.Ask }; }