274 lines
11 KiB
C#
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);
|
|
}
|
|
}
|