feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Fundamentals;
|
||||
using FinlyticCore.Dtos.Sentiment;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
|
||||
namespace FinlyticEngine.Services.Scoring;
|
||||
|
||||
public record ScoringResult(
|
||||
decimal CompositeScore,
|
||||
decimal TechnicalScore,
|
||||
decimal SentimentScore,
|
||||
decimal FundamentalScore,
|
||||
bool PassedEarningsLockout,
|
||||
int? DaysToNextEarnings,
|
||||
decimal ReliabilityBonus = 0m,
|
||||
bool PassedSimulationVeto = true,
|
||||
bool PassedDividendGate = true,
|
||||
int? DaysToNextExDividend = null
|
||||
);
|
||||
|
||||
public interface ICompositeOpportunityScorer
|
||||
{
|
||||
Task<ScoringResult> CalculateCompositeScoreAsync(
|
||||
StrategyResultDto setup,
|
||||
IsinSentimentSummaryDto? sentiment,
|
||||
AssetFundamentalsDto? fundamentals,
|
||||
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user