168 lines
7.7 KiB
C#
168 lines
7.7 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FinlyticCore.Dtos.Fundamentals;
|
|
using FinlyticCore.Dtos.Sentiment;
|
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
|
using FinlyticCore.Services;
|
|
using FinlyticEngine.Settings;
|
|
|
|
namespace FinlyticEngine.Services.Scoring;
|
|
|
|
public class CompositeOpportunityScorer : ICompositeOpportunityScorer
|
|
{
|
|
private readonly ISettingsService _settingsService;
|
|
private readonly IFinlyticLogger<CompositeOpportunityScorer> _logger;
|
|
|
|
public CompositeOpportunityScorer(
|
|
ISettingsService settingsService,
|
|
IFinlyticLogger<CompositeOpportunityScorer> logger)
|
|
{
|
|
_settingsService = settingsService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<ScoringResult> CalculateCompositeScoreAsync(
|
|
StrategyResultDto setup,
|
|
IsinSentimentSummaryDto? sentiment,
|
|
AssetFundamentalsDto? fundamentals,
|
|
FinlyticCore.Dtos.Simulation.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)
|
|
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)
|
|
decimal sFund = 50m;
|
|
if (fundamentals?.Fundamentals != null)
|
|
{
|
|
var fund = fundamentals.Fundamentals;
|
|
decimal baseScore = 50m;
|
|
|
|
// Fwd PE evaluation
|
|
if (fund.ForwardPe.HasValue && fund.ForwardPe.Value > 0)
|
|
{
|
|
if (fund.ForwardPe.Value < 20m) baseScore += 10m;
|
|
else if (fund.ForwardPe.Value > 45m) baseScore -= 10m;
|
|
}
|
|
|
|
// Return on Equity evaluation
|
|
if (fund.ReturnOnEquity.HasValue)
|
|
{
|
|
if (fund.ReturnOnEquity.Value > 0.15m) baseScore += 10m;
|
|
else if (fund.ReturnOnEquity.Value < 0.0m) baseScore -= 15m;
|
|
}
|
|
|
|
// Analyst rating
|
|
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;
|
|
}
|
|
|
|
sFund = Math.Clamp(baseScore, 0m, 100m);
|
|
}
|
|
|
|
// 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,
|
|
"[CompositeScorer] ISIN {Isin} hit earnings lockout ({Days} days to earnings). Suppressing score.",
|
|
setup.Isin, daysToEarnings.Value);
|
|
}
|
|
|
|
// 4b. Dividend Gate Check - moderate suppression around the ex-dividend date. Milder than the earnings
|
|
// lockout above (mDividend = 0.5 vs. mEarnings = 0.15) because an ex-dividend price adjustment is a
|
|
// predictable, mechanical gap-down roughly equal to the dividend amount, not a fundamental surprise -
|
|
// but it still distorts technical patterns/indicators enough to warrant caution, not a hard veto.
|
|
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 - milder than earnings/simulation-veto
|
|
await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel,
|
|
"[CompositeScorer] 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,
|
|
"[CompositeScorer] 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,
|
|
"[CompositeScorer] 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,
|
|
"[CompositeScorer] ISIN {Isin} evaluated: COS={Cos:F1} (Tech={Tech:F1}, Sent={Sent:F1}, Fund={Fund:F1}, Bonus={Bonus}, Veto={Veto}, Lockout={Lockout}, DividendGate={DividendGate})",
|
|
setup.Isin, 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
|
|
);
|
|
}
|
|
|
|
}
|