Files
Finlytic/FinlyticEngine.Tests/Services/Scoring/CompositeOpportunityScorerV2Tests.cs

274 lines
11 KiB
C#

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<CompositeOpportunityScorerV2> _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<string, decimal>(),
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);
}
}