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