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 { /// /// Evaluates technical setup, indicators, and patterns for an ISIN and returns trading setups. /// /// /// Which universe-selection mechanism this ISIN is currently monitored under (favorite/discovery/ /// sentiment-spike), if known - passed through onto the returned s and /// persisted alongside them so downstream consumers (FinlyticEngine) can record why the asset was being /// watched. for an ad hoc analysis outside the scan universe. /// /// When the ISIN entered that universe, alongside . Task> AnalyzeIsinAsync(string isin, string? symbol = null, UniverseSource? universeSource = null, DateTime? universeEnteredAtUtc = null, CancellationToken cancellationToken = default); /// /// Gets full technical analysis including candles, calculated indicators, patterns, and signals for an ISIN. /// Task GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default); /// /// Gets all active top-pick setups from the database. /// Task> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default); /// /// Returns the last setups persisted for across all scan /// cycles, most recent first - regardless of IsActive/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. /// Task> 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 _patternDetectors; private readonly IEnumerable _strategies; private readonly IFinlyticLogger _logger; public TechnicalScoringEngine( IServiceScopeFactory scopeFactory, IMultiTimeframeCandleAggregator aggregator, IYahooMarketDataScraper yahooScraper, IEnumerable patternDetectors, IEnumerable strategies, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _aggregator = aggregator; _yahooScraper = yahooScraper; _patternDetectors = patternDetectors; _strategies = strategies; _logger = logger; } public async Task> 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(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(); 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(); 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 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 > 0m && e50 > 0m && 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) && vwap > 0m && 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 > 0m && e50 > 0m && 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 patterns, List setups) { try { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // 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> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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(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(e.UniverseSource, out var universeSource) ? universeSource : null, UniverseEnteredAtUtc: e.UniverseEnteredAtUtc, Regime: Enum.TryParse(e.Regime, out var regimeParsed) ? regimeParsed : null )).ToList(); } public async Task> 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(); 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(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(e.UniverseSource, out var universeSource) ? universeSource : null, UniverseEnteredAtUtc: e.UniverseEnteredAtUtc, Regime: Enum.TryParse(e.Regime, out var regimeParsed) ? regimeParsed : null )).ToList(); } public async Task 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(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(); foreach (var detector in _patternDetectors) { try { var pat = detector.Evaluate(context); if (pat != null) { detectedPatterns.Add(pat); } } catch { } } var indicatorList = new List(); 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 { 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 { 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" ); } }