feat(technicals,engine): add V2 multi-timeframe scoring, SMC patterns, and COS V2 engine
This commit is contained in:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user