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); } }