feat(technicals,engine): add V2 multi-timeframe scoring, SMC patterns, and COS V2 engine

This commit is contained in:
2026-09-01 17:38:13 +02:00
parent c5d7d359ba
commit cb8a169043
10 changed files with 1386 additions and 12 deletions
@@ -0,0 +1,227 @@
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;
/// <summary>
/// V2 Implementation of <see cref="ICompositeOpportunityScorer"/> featuring direction-aware fundamental
/// evaluation (Long vs Short), symmetrical sentiment scaling, and short-squeeze awareness.
/// </summary>
public class CompositeOpportunityScorerV2 : ICompositeOpportunityScorer
{
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<CompositeOpportunityScorerV2> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CompositeOpportunityScorerV2"/> class.
/// </summary>
public CompositeOpportunityScorerV2(
ISettingsService settingsService,
IFinlyticLogger<CompositeOpportunityScorerV2> logger)
{
_settingsService = settingsService;
_logger = logger;
}
/// <inheritdoc />
public async Task<ScoringResult> 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
);
}
/// <summary>
/// Computes directional fundamental score tailored specifically for Buy vs Sell opportunities.
/// </summary>
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);
}
}