From cb8a169043f593f929b4398fecf2fcae33a4967d Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Tue, 1 Sep 2026 17:38:13 +0200 Subject: [PATCH] feat(technicals,engine): add V2 multi-timeframe scoring, SMC patterns, and COS V2 engine --- .../CompositeOpportunityScorerV2Tests.cs | 273 ++++++++ FinlyticEngine/Program.cs | 3 +- .../Services/Ai/AiReasoningGateService.cs | 21 +- .../Scoring/CompositeOpportunityScorerV2.cs | 227 +++++++ .../ActiveTradeMonitoringBackgroundService.cs | 1 + .../Services/Trading/TradeLifecycleService.cs | 1 + .../ChartPatterns/ChartPatternDetectorsV2.cs | 199 ++++++ .../SmartMoney/SmcPatternDetectorsV2.cs | 55 ++ FinlyticTechnicals/Program.cs | 7 +- .../Services/TechnicalScoringEngineV2.cs | 611 ++++++++++++++++++ 10 files changed, 1386 insertions(+), 12 deletions(-) create mode 100644 FinlyticEngine.Tests/Services/Scoring/CompositeOpportunityScorerV2Tests.cs create mode 100644 FinlyticEngine/Services/Scoring/CompositeOpportunityScorerV2.cs create mode 100644 FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectorsV2.cs create mode 100644 FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectorsV2.cs create mode 100644 FinlyticTechnicals/Services/TechnicalScoringEngineV2.cs diff --git a/FinlyticEngine.Tests/Services/Scoring/CompositeOpportunityScorerV2Tests.cs b/FinlyticEngine.Tests/Services/Scoring/CompositeOpportunityScorerV2Tests.cs new file mode 100644 index 0000000..5fc188c --- /dev/null +++ b/FinlyticEngine.Tests/Services/Scoring/CompositeOpportunityScorerV2Tests.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.Simulation; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticEngine.Services.Scoring; +using FinlyticEngine.Settings; +using FinlyticEngine.Tests.TestSupport; +using Xunit; + +namespace FinlyticEngine.Tests.Services.Scoring; + +public class CompositeOpportunityScorerV2Tests +{ + private readonly FakeSettingsService _settings = new(); + private readonly FakeFinlyticLogger _logger = new(); + private readonly CompositeOpportunityScorerV2 _scorer; + + public CompositeOpportunityScorerV2Tests() + { + _scorer = new CompositeOpportunityScorerV2(_settings, _logger); + } + + private static StrategyResultDto CreateSetup(SignalDirection direction, decimal qualityScore = 85m) + { + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: "US0378331005", + Symbol: "AAPL", + Timeframe: "15m", + StrategyKey: "TrendPullbackFvg", + StrategyName: "Trend Pullback FVG", + Direction: direction, + QualityScore: qualityScore, + CurrentPrice: 150m, + EntryPrice: 150m, + InvalidationPrice: direction == SignalDirection.Buy ? 145m : 155m, + CurrentAtr: 2.5m, + EstimatedRiskRewardRatio: 2.0m, + ExitPlan: TestData.SimpleExitPlan(), + TechnicalRationale: "Test setup", + TriggeringPatterns: [], + IndicatorSnapshot: new Dictionary(), + CreatedAt: DateTime.UtcNow, + ExpiresAt: DateTime.UtcNow.AddHours(4), + IsTopPick: true, + Rating: "A" + ); + } + + [Fact] + public async Task CalculateCompositeScoreAsync_BuyDirection_BullishFundamentals_ScoresHigh() + { + // Arrange + var setup = CreateSetup(SignalDirection.Buy, qualityScore: 85m); + var sentiment = new IsinSentimentSummaryDto + { + Isin = "US0378331005", + CurrentSummary = new IsinCurrentSummary + { + SentimentLabel = "POSITIVE", + CompoundScore = 0.8, + TotalArticlesAnalyzed = 15, + PositiveArticles = 12, + NegativeArticles = 1, + NeutralArticles = 2, + Trend = "IMPROVING", + KeyHighlight = "Strong quarterly earnings surprise" + } + }; + + var fundamentals = new AssetFundamentalsDto + { + Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Apple Inc." }, + Fundamentals = new FundamentalDataDto + { + MarketCap = 3000000000000m, + ForwardPe = 18m, // Low PE -> +10 + TrailingPe = 22m, + PriceToBook = 10m, + ReturnOnEquity = 0.25m, // High ROE -> +10 + TotalRevenue = 1000000000m, + RevenueGrowthYoY = 0.15m, + OperatingIncome = 300000000m, + NetIncome = 250000000m, + DebtToEquity = 1.2m, + FreeCashFlow = 200000000m, + ConsensusRating = "Strong_Buy", // Strong Buy -> +10 + PriceTargetMean = 180m, + ShortPercentOfFloat = 0.02m + }, + Events = + [ + new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }, + new CorporateEventDto { Type = "Dividend", Date = DateTime.UtcNow.AddDays(30) } + ], + LastUpdatedAt = DateTime.UtcNow + }; + + // Act + var result = await _scorer.CalculateCompositeScoreAsync(setup, sentiment, fundamentals); + + // Assert + Assert.True(result.FundamentalScore >= 80m, $"Expected FundamentalScore >= 80, but got {result.FundamentalScore}"); + Assert.True(result.SentimentScore >= 85m, $"Expected SentimentScore >= 85, but got {result.SentimentScore}"); + Assert.True(result.CompositeScore >= 80m, $"Expected CompositeScore >= 80, but got {result.CompositeScore}"); + } + + [Fact] + public async Task CalculateCompositeScoreAsync_BuyDirection_BearishFundamentals_ScoresLow() + { + // Arrange: Buy setup with awful fundamentals + var setup = CreateSetup(SignalDirection.Buy, qualityScore: 85m); + var fundamentals = new AssetFundamentalsDto + { + Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Loss Making Corp" }, + Fundamentals = new FundamentalDataDto + { + MarketCap = 1000000000m, + ForwardPe = 65m, // High PE -> -10 + TrailingPe = 70m, + PriceToBook = 5m, + ReturnOnEquity = -0.10m, // Negative ROE -> -15 + TotalRevenue = 100000000m, + RevenueGrowthYoY = -0.20m, + OperatingIncome = -20000000m, + NetIncome = -25000000m, + DebtToEquity = 3.5m, // High debt -> -10 + FreeCashFlow = -30000000m, + ConsensusRating = "Underperform", // Sell/Underperform -> -15 + PriceTargetMean = 80m, + ShortPercentOfFloat = 0.15m + }, + Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }], + LastUpdatedAt = DateTime.UtcNow + }; + + // Act + var result = await _scorer.CalculateCompositeScoreAsync(setup, null, fundamentals); + + // Assert + Assert.True(result.FundamentalScore <= 15m, $"Expected FundamentalScore <= 15 for bad fundamentals on Buy, but got {result.FundamentalScore}"); + } + + [Fact] + public async Task CalculateCompositeScoreAsync_SellDirection_BearishFundamentals_ScoresHigh() + { + // Arrange: Sell setup on an overvalued, unprofitable company with Sell rating & negative sentiment + var setup = CreateSetup(SignalDirection.Sell, qualityScore: 85m); + var sentiment = new IsinSentimentSummaryDto + { + Isin = "US0378331005", + CurrentSummary = new IsinCurrentSummary + { + SentimentLabel = "NEGATIVE", + CompoundScore = -0.8, // Strong negative sentiment -> should score 90 for Sell! + TotalArticlesAnalyzed = 15, + PositiveArticles = 1, + NegativeArticles = 12, + NeutralArticles = 2, + Trend = "DETERIORATING", + KeyHighlight = "Investigation launched and guidance slashed" + } + }; + + var fundamentals = new AssetFundamentalsDto + { + Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Struggling Tech Corp" }, + Fundamentals = new FundamentalDataDto + { + MarketCap = 1000000000m, + ForwardPe = 60m, // High PE -> +12 for Short + TrailingPe = 70m, + PriceToBook = 5m, + ReturnOnEquity = -0.15m, // Negative ROE -> +15 for Short + TotalRevenue = 100000000m, + RevenueGrowthYoY = -0.30m, + OperatingIncome = -20000000m, + NetIncome = -25000000m, + DebtToEquity = 3.0m, // High debt -> +10 for Short + FreeCashFlow = -30000000m, + ConsensusRating = "Underperform", // Sell/Underperform -> +15 for Short + PriceTargetMean = 60m, + ShortPercentOfFloat = 0.10m // Moderate short interest -> +5 for Short + }, + Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }], + LastUpdatedAt = DateTime.UtcNow + }; + + // Act + var result = await _scorer.CalculateCompositeScoreAsync(setup, sentiment, fundamentals); + + // Assert + Assert.True(result.FundamentalScore >= 90m, $"Expected FundamentalScore >= 90 for ideal short fundamentals, but got {result.FundamentalScore}"); + Assert.True(result.SentimentScore >= 85m, $"Expected SentimentScore >= 85 for bearish sentiment on Sell, but got {result.SentimentScore}"); + Assert.True(result.CompositeScore >= 85m, $"Expected CompositeScore >= 85 for ideal short setup, but got {result.CompositeScore}"); + } + + [Fact] + public async Task CalculateCompositeScoreAsync_SellDirection_BullishFundamentals_ScoresLow() + { + // Arrange: Sell setup on a high quality, profitable, cheap company + var setup = CreateSetup(SignalDirection.Sell, qualityScore: 85m); + var fundamentals = new AssetFundamentalsDto + { + Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "High Quality Value Inc." }, + Fundamentals = new FundamentalDataDto + { + MarketCap = 3000000000000m, + ForwardPe = 12m, // Cheap PE -> -12 for Short (hard to fall further) + TrailingPe = 14m, + PriceToBook = 2m, + ReturnOnEquity = 0.35m, // High ROE -> -12 for Short (cash cow resilience) + TotalRevenue = 1000000000m, + RevenueGrowthYoY = 0.20m, + OperatingIncome = 300000000m, + NetIncome = 250000000m, + DebtToEquity = 0.5m, + FreeCashFlow = 200000000m, + ConsensusRating = "Strong_Buy", // Strong buy -> -15 for Short + PriceTargetMean = 220m, + ShortPercentOfFloat = 0.01m + }, + Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }], + LastUpdatedAt = DateTime.UtcNow + }; + + // Act + var result = await _scorer.CalculateCompositeScoreAsync(setup, null, fundamentals); + + // Assert + Assert.True(result.FundamentalScore <= 20m, $"Expected FundamentalScore <= 20 for shorting a healthy company, but got {result.FundamentalScore}"); + } + + [Fact] + public async Task CalculateCompositeScoreAsync_SellDirection_HighShortFloat_AppliesSqueezeRiskPenalty() + { + // Arrange: Sell setup with excessive short float (> 25%) indicating short squeeze risk + var setup = CreateSetup(SignalDirection.Sell, qualityScore: 80m); + var fundamentals = new AssetFundamentalsDto + { + Asset = new AssetHeaderDto { Isin = "US0378331005", Name = "Heavily Shorted Corp" }, + Fundamentals = new FundamentalDataDto + { + MarketCap = 1000000000m, + ForwardPe = 30m, + TrailingPe = 35m, + PriceToBook = 3m, + ReturnOnEquity = 0.05m, + TotalRevenue = 100000000m, + RevenueGrowthYoY = 0.02m, + OperatingIncome = 5000000m, + NetIncome = 3000000m, + DebtToEquity = 1.0m, + FreeCashFlow = 2000000m, + ConsensusRating = "Hold", + PriceTargetMean = 100m, + ShortPercentOfFloat = 0.35m // 35% float shorted -> Squeeze danger! + }, + Events = [new CorporateEventDto { Type = "Earnings", Date = DateTime.UtcNow.AddDays(45) }], + LastUpdatedAt = DateTime.UtcNow + }; + + // Act + var result = await _scorer.CalculateCompositeScoreAsync(setup, null, fundamentals); + + // Base score: 50 - 10 (squeeze penalty) = 40 + Assert.Equal(40m, result.FundamentalScore); + } +} diff --git a/FinlyticEngine/Program.cs b/FinlyticEngine/Program.cs index d23849d..fcdb75a 100644 --- a/FinlyticEngine/Program.cs +++ b/FinlyticEngine/Program.cs @@ -34,7 +34,8 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService sp.GetRequiredService()); // 5. Register Engine Domain Services -builder.Services.AddSingleton(); +// builder.Services.AddSingleton(); // V1 Fallback +builder.Services.AddSingleton(); // V2 Bidirectional Active builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/FinlyticEngine/Services/Ai/AiReasoningGateService.cs b/FinlyticEngine/Services/Ai/AiReasoningGateService.cs index 917027a..7156595 100644 --- a/FinlyticEngine/Services/Ai/AiReasoningGateService.cs +++ b/FinlyticEngine/Services/Ai/AiReasoningGateService.cs @@ -27,16 +27,17 @@ public class AiReasoningGateService : IAiReasoningGateService /// on a single external configuration surface. /// private const string BaseInstructions = - "Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System. " + - "Bewerte, ob das folgende technische Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " + - "insbesondere: (1) Widersprechen sich technisches Signal, Sentiment-Lage und Fundamentaldaten? " + - "(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen bevorstehenden, schwer " + - "kalkulierbaren Kurssprung hin? (3) Was sagt die Backtest-Historie (falls vorhanden) über die " + - "Zuverlässigkeit dieser Strategie für genau dieses Asset? (4) Passt das Risk/Reward-Verhältnis zum " + - "aktuellen Markt-Regime? Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem " + - "Schema, ohne Text davor oder danach: {\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), " + - "\"thesisSummary\": string, \"invalidationReason\": string, \"keyCatalysts\": string[], " + - "\"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " + + "Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System für Long- und Short-Strategien. " + + "Bewerte richtungsbezogen (Long/Buy oder Short/Sell), ob das folgende Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " + + "insbesondere: (1) Widersprechen sich Signal-Richtung, technisches Muster, Sentiment und Fundamentaldaten? " + + "(Bei Long: stützen Momentum, News und Bewertung steigende Kurse? Bei Short: stützen bärische Muster, negatives Sentiment " + + "oder schwache/überbewertete Fundamentaldaten fallende Kurse ohne extreme Squeeze-Gefahr?) " + + "(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen schwer kalkulierbaren Kurssprung (Gap) gegen die Position hin? " + + "(3) Was sagt die Backtest-Historie (falls vorhanden) über die Zuverlässigkeit dieser Strategie für dieses Asset aus? " + + "(4) Passt das Risk/Reward-Verhältnis zum aktuellen Markt-Regime? " + + "Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem Schema, ohne Text davor oder danach: " + + "{\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), \"thesisSummary\": string, \"invalidationReason\": string, " + + "\"keyCatalysts\": string[], \"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " + "ist günstiger als ein falscher."; private readonly HttpClient _httpClient; diff --git a/FinlyticEngine/Services/Scoring/CompositeOpportunityScorerV2.cs b/FinlyticEngine/Services/Scoring/CompositeOpportunityScorerV2.cs new file mode 100644 index 0000000..32a8960 --- /dev/null +++ b/FinlyticEngine/Services/Scoring/CompositeOpportunityScorerV2.cs @@ -0,0 +1,227 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.Simulation; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; +using FinlyticEngine.Settings; + +namespace FinlyticEngine.Services.Scoring; + +/// +/// V2 Implementation of featuring direction-aware fundamental +/// evaluation (Long vs Short), symmetrical sentiment scaling, and short-squeeze awareness. +/// +public class CompositeOpportunityScorerV2 : ICompositeOpportunityScorer +{ + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public CompositeOpportunityScorerV2( + ISettingsService settingsService, + IFinlyticLogger logger) + { + _settingsService = settingsService; + _logger = logger; + } + + /// + public async Task CalculateCompositeScoreAsync( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + AssetFundamentalsDto? fundamentals, + StrategyAssetReliabilityDto? reliability = null, + CancellationToken cancellationToken = default) + { + var wTech = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightTechnical, cancellationToken); + var wSent = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightSentiment, cancellationToken); + var wFund = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightFundamental, cancellationToken); + var lockoutDays = await _settingsService.GetSettingAsync(EngineSettingKeys.EarningsLockoutDays, cancellationToken); + var dividendGateDays = await _settingsService.GetSettingAsync(EngineSettingKeys.DividendGateDays, cancellationToken); + + // 1. Technical Score (0..100) + decimal sTech = Math.Clamp(setup.QualityScore, 0m, 100m); + + // 2. Sentiment Score (0..100) - Direction aware + decimal sSent = 50m; + if (sentiment?.CurrentSummary != null) + { + decimal compound = (decimal)sentiment.CurrentSummary.CompoundScore; // -1.0 .. +1.0 + if (setup.Direction == SignalDirection.Buy) + { + // Compound: -1.0 -> 0, 0.0 -> 50, +1.0 -> 100 + sSent = Math.Clamp(((compound + 1.0m) / 2.0m) * 100m, 0m, 100m); + } + else if (setup.Direction == SignalDirection.Sell) + { + // Compound: +1.0 -> 0, 0.0 -> 50, -1.0 -> 100 + sSent = Math.Clamp(((1.0m - compound) / 2.0m) * 100m, 0m, 100m); + } + } + + // 3. Fundamental Score (0..100) - V2 Direction Aware (Long vs Short) + decimal sFund = 50m; + if (fundamentals?.Fundamentals != null) + { + sFund = CalculateDirectionalFundamentalScore(fundamentals.Fundamentals, setup.Direction); + } + + // 4. Earnings Lockout Check + int? daysToEarnings = fundamentals?.DaysToNextEarnings; + bool passedLockout = true; + decimal mEarnings = 1.0m; + + if (daysToEarnings.HasValue && daysToEarnings.Value <= lockoutDays && daysToEarnings.Value >= 0) + { + passedLockout = false; + mEarnings = 0.15m; // Strong suppression penalty + await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorerV2] ISIN {Isin} hit earnings lockout ({Days} days to earnings). Suppressing score.", + setup.Isin, daysToEarnings.Value); + } + + // 4b. Dividend Gate Check + int? daysToExDividend = fundamentals?.DaysToNextExDividend; + bool passedDividendGate = true; + decimal mDividend = 1.0m; + + if (daysToExDividend.HasValue && daysToExDividend.Value <= dividendGateDays && daysToExDividend.Value >= 0) + { + passedDividendGate = false; + mDividend = 0.5m; // Moderate suppression penalty + await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorerV2] ISIN {Isin} hit dividend gate ({Days} days to ex-dividend). Suppressing score.", + setup.Isin, daysToExDividend.Value); + } + + // 5. Backtesting Matrix Feedback-Loop (Score-Bonus or Veto) + decimal matrixBonus = 0m; + bool passedVeto = true; + decimal mVeto = 1.0m; + + if (reliability != null) + { + if (reliability.RecommendedAction == "BOOST_SCORE" || (reliability.ProfitFactor >= 1.60m && reliability.SampleTradeCount >= 5)) + { + matrixBonus = 15.0m; + await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorerV2] Simulation matrix bonus (+15 pts) applied for {Isin} ({Strategy}): PF={PF:F2}, WR={WR:F1}%", + setup.Isin, setup.StrategyKey, reliability.ProfitFactor, reliability.WinRatePercent); + } + else if (reliability.RecommendedAction == "VETO_DISABLE" || (!reliability.IsStrategyApprovedForAsset && reliability.SampleTradeCount >= 5)) + { + passedVeto = false; + mVeto = 0.20m; // Heavy suppression penalty + await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorerV2] Simulation matrix VETO applied for {Isin} ({Strategy}): PF={PF:F2} < 1.00. Suppressing score.", + setup.Isin, setup.StrategyKey, reliability.ProfitFactor); + } + } + + // 6. Calculate Weighted Composite Opportunity Score (COS) + decimal rawScore = (wTech * sTech) + (wSent * sSent) + (wFund * sFund) + matrixBonus; + decimal finalCos = Math.Clamp(rawScore * mEarnings * mDividend * mVeto, 0m, 100m); + + await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorerV2] ISIN {Isin} ({Direction}) evaluated: COS={Cos:F1} (Tech={Tech:F1}, Sent={Sent:F1}, Fund={Fund:F1}, Bonus={Bonus}, Veto={Veto}, Lockout={Lockout}, DividendGate={DividendGate})", + setup.Isin, setup.Direction, finalCos, sTech, sSent, sFund, matrixBonus, passedVeto, passedLockout, passedDividendGate); + + return new ScoringResult( + CompositeScore: Math.Round(finalCos, 2), + TechnicalScore: Math.Round(sTech, 2), + SentimentScore: Math.Round(sSent, 2), + FundamentalScore: Math.Round(sFund, 2), + PassedEarningsLockout: passedLockout, + DaysToNextEarnings: daysToEarnings, + ReliabilityBonus: matrixBonus, + PassedSimulationVeto: passedVeto, + PassedDividendGate: passedDividendGate, + DaysToNextExDividend: daysToExDividend + ); + } + + /// + /// Computes directional fundamental score tailored specifically for Buy vs Sell opportunities. + /// + private static decimal CalculateDirectionalFundamentalScore(FundamentalDataDto fund, SignalDirection direction) + { + decimal baseScore = 50m; + + if (direction == SignalDirection.Buy) + { + // Forward P/E: Low valuation supports Long (+10), extreme overvaluation penalizes (-10) + if (fund.ForwardPe.HasValue) + { + if (fund.ForwardPe.Value > 0 && fund.ForwardPe.Value < 20m) baseScore += 10m; + else if (fund.ForwardPe.Value > 45m || fund.ForwardPe.Value <= 0) baseScore -= 10m; + } + + // Return on Equity: Profitable return on equity supports Long (+10), capital destruction penalizes (-15) + if (fund.ReturnOnEquity.HasValue) + { + if (fund.ReturnOnEquity.Value > 0.15m) baseScore += 10m; + else if (fund.ReturnOnEquity.Value < 0.0m) baseScore -= 15m; + } + + // Analyst Consensus + if (!string.IsNullOrWhiteSpace(fund.ConsensusRating)) + { + var r = fund.ConsensusRating.ToLowerInvariant(); + if (r.Contains("buy") || r.Contains("strong_buy") || r.Contains("outperform")) baseScore += 10m; + else if (r.Contains("sell") || r.Contains("underperform")) baseScore -= 15m; + } + + // Debt to Equity penalty for highly leveraged balance sheets on Longs + if (fund.DebtToEquity.HasValue && fund.DebtToEquity.Value > 2.5m) + { + baseScore -= 10m; + } + } + else if (direction == SignalDirection.Sell) + { + // Symmetrical Short evaluation: + // Forward P/E: Extreme valuation or negative earnings supports Short (+12), deep value penalizes (-12) + if (fund.ForwardPe.HasValue) + { + if (fund.ForwardPe.Value > 45m || fund.ForwardPe.Value <= 0) baseScore += 12m; + else if (fund.ForwardPe.Value > 0 && fund.ForwardPe.Value < 15m) baseScore -= 12m; + } + + // Return on Equity: Capital destruction / losses supports Short (+15), high cash cow returns penalizes (-12) + if (fund.ReturnOnEquity.HasValue) + { + if (fund.ReturnOnEquity.Value < 0.0m) baseScore += 15m; + else if (fund.ReturnOnEquity.Value > 0.25m) baseScore -= 12m; + } + + // Analyst Consensus: Downgrades and Sell ratings confirm Short (+15), Strong Buy opposes Short (-15) + if (!string.IsNullOrWhiteSpace(fund.ConsensusRating)) + { + var r = fund.ConsensusRating.ToLowerInvariant(); + if (r.Contains("sell") || r.Contains("underperform") || r.Contains("downgrade")) baseScore += 15m; + else if (r.Contains("strong_buy") || r.Contains("outperform")) baseScore -= 15m; + } + + // High Debt to Equity adds vulnerability in downtrend (+10) + if (fund.DebtToEquity.HasValue && fund.DebtToEquity.Value > 2.5m) + { + baseScore += 10m; + } + + // Short Interest Float check: moderate short interest (5-15%) confirms short thesis (+5), + // but extreme short interest (>25%) warns of dangerous short squeeze risk (-10) + if (fund.ShortPercentOfFloat.HasValue) + { + if (fund.ShortPercentOfFloat.Value is >= 0.05m and <= 0.15m) baseScore += 5m; + else if (fund.ShortPercentOfFloat.Value > 0.25m) baseScore -= 10m; + } + } + + return Math.Clamp(baseScore, 0m, 100m); + } +} diff --git a/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs b/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs index 12e401d..c213d00 100644 --- a/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs +++ b/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs @@ -221,6 +221,7 @@ public class ActiveTradeMonitoringBackgroundService : BackgroundService return new ActiveTradeDto( TradeId: e.Id, ProposalId: e.ProposalId, + UserId: e.UserId, UnderlyingIsin: e.UnderlyingIsin, Symbol: e.Symbol, DerivativeIsin: e.DerivativeIsin, diff --git a/FinlyticEngine/Services/Trading/TradeLifecycleService.cs b/FinlyticEngine/Services/Trading/TradeLifecycleService.cs index 9e74489..40ce3ec 100644 --- a/FinlyticEngine/Services/Trading/TradeLifecycleService.cs +++ b/FinlyticEngine/Services/Trading/TradeLifecycleService.cs @@ -889,6 +889,7 @@ public class TradeLifecycleService : ITradeLifecycleService return new ActiveTradeDto( TradeId: e.Id, ProposalId: e.ProposalId, + UserId: e.UserId, UnderlyingIsin: e.UnderlyingIsin, Symbol: e.Symbol, DerivativeIsin: e.DerivativeIsin, diff --git a/FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectorsV2.cs b/FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectorsV2.cs new file mode 100644 index 0000000..77cbbc3 --- /dev/null +++ b/FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectorsV2.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Patterns.ChartPatterns; + +/// +/// Detects Double Top (M-reversal) formation where price tests a major resistance peak twice and breaks lower. +/// +public class DoubleTopDetector : IPatternDetector +{ + /// + public PatternType HandledType => PatternType.DoubleTop; + + /// + public PatternCategory Category => PatternCategory.Chart; + + /// + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 25) return null; + + var recent = candles.TakeLast(25).ToList(); + + decimal max1 = decimal.MinValue; + int max1Idx = -1; + decimal max2 = decimal.MinValue; + int max2Idx = -1; + decimal troughBetween = decimal.MaxValue; + + // Search for two prominent swing highs + for (int i = 2; i < recent.Count - 2; i++) + { + if (recent[i].High >= recent[i - 1].High && recent[i].High >= recent[i - 2].High && + recent[i].High >= recent[i + 1].High && recent[i].High >= recent[i + 2].High) + { + if (max1Idx == -1) + { + max1 = recent[i].High; + max1Idx = i; + } + else if (max2Idx == -1 && i > max1Idx + 4) + { + max2 = recent[i].High; + max2Idx = i; + break; + } + } + } + + if (max1Idx != -1 && max2Idx != -1) + { + // Calculate trough between the two highs (neckline) + for (int i = max1Idx; i <= max2Idx; i++) + { + if (recent[i].Low < troughBetween) troughBetween = recent[i].Low; + } + + decimal priceDifference = Math.Abs(max1 - max2) / max1; + var current = recent.Last(); + + // Double Top validation: highs within 1.5% of each other, neckline clearly below highs + if (priceDifference <= 0.015m && current.Close <= max2 && troughBetween < max1 * 0.99m) + { + decimal target = troughBetween - (Math.Max(max1, max2) - troughBetween); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.DoubleTop, + Category: PatternCategory.Chart, + Bias: PatternBias.Bearish, + Name: "Double Top (M-Pattern)", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: troughBetween, + UpperBoundary: Math.Max(max1, max2), + LowerBoundary: target, + InvalidationLevel: Math.Max(max1, max2) * 1.005m, + QualityScore: 82m, + Description: $"Double top with peaks at {max1:F2} & {max2:F2}, neckline support at {troughBetween:F2}." + ); + } + } + + return null; + } +} + +/// +/// Detects Inverse Head & Shoulders (bullish reversal) formation. +/// +public class InverseHeadAndShouldersDetector : IPatternDetector +{ + /// + public PatternType HandledType => PatternType.InverseHeadAndShoulders; + + /// + public PatternCategory Category => PatternCategory.Chart; + + /// + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 30) return null; + + var recent = candles.TakeLast(30).ToList(); + + // Look for Left Shoulder Low, Head Low (lowest), Right Shoulder Low + decimal minPrice = recent.Min(c => c.Low); + int headIdx = recent.FindIndex(c => c.Low == minPrice); + + if (headIdx >= 5 && headIdx <= recent.Count - 5) + { + decimal leftShoulderLow = recent.Take(headIdx).Min(c => c.Low); + decimal rightShoulderLow = recent.Skip(headIdx + 1).Min(c => c.Low); + + // Head must be strictly lower than both shoulders + if (minPrice < leftShoulderLow * 0.99m && minPrice < rightShoulderLow * 0.99m && + Math.Abs(leftShoulderLow - rightShoulderLow) / leftShoulderLow <= 0.03m) + { + decimal neckline = recent.Skip(headIdx - 3).Take(6).Max(c => c.High); + var current = recent.Last(); + + if (current.Close >= rightShoulderLow) + { + decimal target = neckline + (neckline - minPrice); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.InverseHeadAndShoulders, + Category: PatternCategory.Chart, + Bias: PatternBias.Bullish, + Name: "Inverse Head & Shoulders", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: neckline, + UpperBoundary: target, + LowerBoundary: minPrice, + InvalidationLevel: minPrice * 0.995m, + QualityScore: 85m, + Description: $"Bullish Inverse Head & Shoulders with Head low at {minPrice:F2}, Shoulders ~{leftShoulderLow:F2}, Neckline at {neckline:F2}." + ); + } + } + } + + return null; + } +} + +/// +/// Detects Descending Triangle (bearish continuation / breakdown) consolidation. +/// +public class DescendingTriangleDetector : IPatternDetector +{ + /// + public PatternType HandledType => PatternType.DescendingTriangle; + + /// + public PatternCategory Category => PatternCategory.Chart; + + /// + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 20) return null; + + var recent = candles.TakeLast(20).ToList(); + decimal lowSupport = recent.Take(15).Min(c => c.Low); + + // Check if lows are flat (horizontal support) while highs are falling (lower highs) + decimal high1 = recent.Take(7).Max(c => c.High); + decimal high2 = recent.Skip(7).Take(7).Max(c => c.High); + decimal high3 = recent.Skip(14).Max(c => c.High); + + if (high3 < high2 && high2 < high1 && Math.Abs(recent.Last().Low - lowSupport) / Math.Max(lowSupport, 0.01m) <= 0.01m) + { + var curr = recent.Last(); + decimal target = lowSupport - (high1 - lowSupport); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.DescendingTriangle, + Category: PatternCategory.Chart, + Bias: PatternBias.Bearish, + Name: "Descending Triangle", + Timeframe: context.Timeframe, + DetectedAt: curr.Timestamp, + KeyPriceLevel: lowSupport, + UpperBoundary: high3, + LowerBoundary: target, + InvalidationLevel: high3 * 1.005m, + QualityScore: 80m, + Description: $"Descending triangle with horizontal support at {lowSupport:F2} and descending highs ({high1:F2} -> {high2:F2} -> {high3:F2})." + ); + } + + return null; + } +} diff --git a/FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectorsV2.cs b/FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectorsV2.cs new file mode 100644 index 0000000..f36867a --- /dev/null +++ b/FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectorsV2.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Patterns.SmartMoney; + +/// +/// Detects institutional Bearish Order Blocks (last bullish candle before a strong downward displacement). +/// +public class BearishOrderBlockDetector : IPatternDetector +{ + /// + public PatternType HandledType => PatternType.OrderBlock; + + /// + public PatternCategory Category => PatternCategory.SmartMoney; + + /// + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 5) return null; + + var obCandle = candles[^3]; + var impulse1 = candles[^2]; + var impulse2 = candles.Last(); + + // Bearish Order Block: Green candle followed by 2 strong red candles that drop price > 1.5 ATR + if (obCandle.Close > obCandle.Open && impulse1.Close < impulse1.Open && impulse2.Close < impulse2.Open) + { + decimal displacement = obCandle.High - impulse2.Close; + if (displacement >= context.CurrentAtr * 1.5m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.OrderBlock, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bearish, + Name: "Bearish Institutional Order Block", + Timeframe: context.Timeframe, + DetectedAt: impulse2.Timestamp, + KeyPriceLevel: (obCandle.Open + obCandle.Close) / 2m, + UpperBoundary: obCandle.High, + LowerBoundary: obCandle.Low, + InvalidationLevel: obCandle.High * 1.005m, + QualityScore: 86m, + Description: $"Bearish order block zone [{obCandle.Low:F2} - {obCandle.High:F2}] with strong downward displacement." + ); + } + } + + return null; + } +} diff --git a/FinlyticTechnicals/Program.cs b/FinlyticTechnicals/Program.cs index 77e4b8d..8f17f80 100644 --- a/FinlyticTechnicals/Program.cs +++ b/FinlyticTechnicals/Program.cs @@ -56,6 +56,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // 6. Register Strategies builder.Services.AddSingleton(); @@ -70,7 +74,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // 7. Register Technical Scoring Engine & Universe Manager -builder.Services.AddSingleton(); +// builder.Services.AddSingleton(); // V1 Fallback +builder.Services.AddSingleton(); // V2 Bidirectional Active builder.Services.AddSingleton(); // 8. Register MQTT Client & RPC Bridge diff --git a/FinlyticTechnicals/Services/TechnicalScoringEngineV2.cs b/FinlyticTechnicals/Services/TechnicalScoringEngineV2.cs new file mode 100644 index 0000000..1e321cb --- /dev/null +++ b/FinlyticTechnicals/Services/TechnicalScoringEngineV2.cs @@ -0,0 +1,611 @@ +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; + +/// +/// V2 Implementation of featuring fully symmetrical, +/// bidirectional (Long & Short) indicator confluence math, direction-aware pattern filtering, +/// and regime-aligned scoring without Long-bias. +/// +public class TechnicalScoringEngineV2 : 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; + + /// + /// Initializes a new instance of the class. + /// + public TechnicalScoringEngineV2( + 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, "[TechnicalScoringEngineV2] 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 symmetrically + 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, + "[TechnicalScoringEngineV2] 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 = CalculateIndicatorConfluenceScoreV2(indicators, setup.Direction, lastCandle.Close); + decimal patternScore = CalculateDirectionalPatternScore(detectedPatterns, setup.Direction); + 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, + "[TechnicalScoringEngineV2] 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; + } + + /// + /// Calculates symmetrical indicator confluence score for both Buy and Sell directions (0..100). + /// + private static decimal CalculateIndicatorConfluenceScoreV2(Dictionary ind, SignalDirection dir, decimal currentPrice) + { + decimal score = 50m; + + if (dir == SignalDirection.Buy) + { + // Trend alignment: Fast EMA above Slow EMA (+15) + if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 > e50) score += 15m; + + // Momentum in bullish expansion / pull-back zone (+15) + if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m; + + // Trend strength confirmation (+10) + if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m; + + // Price acceptance above VWAP (+10) + if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && (currentPrice > vwap || (ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap))) score += 10m; + } + else if (dir == SignalDirection.Sell) + { + // Symmetrical Trend alignment: Fast EMA below Slow EMA (+15) + if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 < e50) score += 15m; + + // Symmetrical Momentum in bearish breakdown / relief-rally zone (+15) + if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m; + + // Trend strength confirmation (+10) + if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m; + + // Symmetrical Price rejection below VWAP (+10) -> enables full 100 points for Sell! + if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && (currentPrice < vwap || (ind.TryGetValue("EMA_20", out var e20b) && e20b > 0m && e20b < vwap))) score += 10m; + } + + return Math.Clamp(score, 0m, 100m); + } + + /// + /// Evaluates detected patterns considering directional bias alignment with the setup. + /// + private static decimal CalculateDirectionalPatternScore(IReadOnlyList patterns, SignalDirection direction) + { + if (patterns.Count == 0) return 50m; + + var expectedBias = direction == SignalDirection.Buy ? PatternBias.Bullish : PatternBias.Bearish; + var opposingBias = direction == SignalDirection.Buy ? PatternBias.Bearish : PatternBias.Bullish; + + var matchingPatterns = patterns.Where(p => p.Bias == expectedBias || p.Bias == PatternBias.Neutral).ToList(); + var opposingPatterns = patterns.Where(p => p.Bias == opposingBias).ToList(); + + if (matchingPatterns.Count == 0 && opposingPatterns.Count > 0) + { + // Conflicting patterns penalize the score + return Math.Max(30m, 50m - (opposingPatterns.Count * 10m)); + } + + if (matchingPatterns.Count > 0) + { + decimal avgQuality = matchingPatterns.Average(p => p.QualityScore); + // Deduct minor penalty if conflicting patterns also exist + decimal penalty = opposingPatterns.Count * 5m; + return Math.Clamp(avgQuality - penalty, 0m, 100m); + } + + return 50m; + } + + /// + 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" + ); + } + + /// + 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) + { + query = query.Where(s => s.QualityScore >= minScore.Value); + } + + var entities = await query + .OrderByDescending(s => s.QualityScore) + .Take(limit) + .ToListAsync(cancellationToken); + + return entities.Select(MapEntityToDto).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(Math.Max(1, limit)) + .ToListAsync(cancellationToken); + + return entities.Select(MapEntityToDto).ToList(); + } + + 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, + "[TechnicalScoringEngineV2] Failed to persist technical setups for ISIN {Isin}", isin); + } + } + + private static StrategyResultDto MapEntityToDto(FtaTechnicalSetupEntity entity) + { + var direction = Enum.TryParse(entity.Direction, true, out var dir) ? dir : SignalDirection.Buy; + UniverseSource? universeSource = !string.IsNullOrWhiteSpace(entity.UniverseSource) && + Enum.TryParse(entity.UniverseSource, true, out var src) + ? src + : null; + MarketRegime? regime = !string.IsNullOrWhiteSpace(entity.Regime) && + Enum.TryParse(entity.Regime, true, out var reg) + ? reg + : null; + + return new StrategyResultDto( + SetupId: entity.SetupId, + Isin: entity.Isin, + Symbol: entity.Symbol, + Timeframe: entity.Timeframe, + StrategyKey: entity.StrategyKey, + StrategyName: entity.StrategyName, + Direction: direction, + QualityScore: entity.QualityScore, + CurrentPrice: entity.CurrentPrice, + EntryPrice: entity.EntryPrice, + InvalidationPrice: entity.InvalidationPrice, + CurrentAtr: entity.CurrentAtr, + EstimatedRiskRewardRatio: entity.EstimatedRiskRewardRatio, + ExitPlan: entity.ExitPlan ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, entity.InvalidationPrice, []), + TechnicalRationale: entity.TechnicalRationale, + TriggeringPatterns: entity.TriggeringPatterns ?? [], + IndicatorSnapshot: entity.IndicatorSnapshot ?? new Dictionary(), + CreatedAt: entity.CreatedAtUtc, + ExpiresAt: entity.ExpiresAtUtc, + IsTopPick: entity.IsTopPick, + Rating: entity.Rating, + UniverseSource: universeSource, + UniverseEnteredAtUtc: entity.UniverseEnteredAtUtc, + Regime: regime + ); + } +}