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,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);
}
}
+2 -1
View File
@@ -34,7 +34,8 @@ builder.Services.AddSingleton<IEngineRpcClient>(sp => sp.GetRequiredService<Engi
builder.Services.AddHostedService(sp => sp.GetRequiredService<EngineMqttClient>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<EngineMqttClient>());
// 5. Register Engine Domain Services // 5. Register Engine Domain Services
builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorer>(); // builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorer>(); // V1 Fallback
builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorerV2>(); // V2 Bidirectional Active
builder.Services.AddSingleton<IKnockOutDerivativeResolver, KnockOutDerivativeResolver>(); builder.Services.AddSingleton<IKnockOutDerivativeResolver, KnockOutDerivativeResolver>();
builder.Services.AddSingleton<ITradeLifecycleService, TradeLifecycleService>(); builder.Services.AddSingleton<ITradeLifecycleService, TradeLifecycleService>();
builder.Services.AddSingleton<IEvaluationHistoryService, EvaluationHistoryService>(); builder.Services.AddSingleton<IEvaluationHistoryService, EvaluationHistoryService>();
@@ -27,16 +27,17 @@ public class AiReasoningGateService : IAiReasoningGateService
/// on a single external configuration surface. /// on a single external configuration surface.
/// </summary> /// </summary>
private const string BaseInstructions = private const string BaseInstructions =
"Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System. " + "Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System für Long- und Short-Strategien. " +
"Bewerte, ob das folgende technische Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " + "Bewerte richtungsbezogen (Long/Buy oder Short/Sell), ob das folgende Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " +
"insbesondere: (1) Widersprechen sich technisches Signal, Sentiment-Lage und Fundamentaldaten? " + "insbesondere: (1) Widersprechen sich Signal-Richtung, technisches Muster, Sentiment und Fundamentaldaten? " +
"(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen bevorstehenden, schwer " + "(Bei Long: stützen Momentum, News und Bewertung steigende Kurse? Bei Short: stützen bärische Muster, negatives Sentiment " +
"kalkulierbaren Kurssprung hin? (3) Was sagt die Backtest-Historie (falls vorhanden) über die " + "oder schwache/überbewertete Fundamentaldaten fallende Kurse ohne extreme Squeeze-Gefahr?) " +
"Zuverlässigkeit dieser Strategie für genau dieses Asset? (4) Passt das Risk/Reward-Verhältnis zum " + "(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen schwer kalkulierbaren Kurssprung (Gap) gegen die Position hin? " +
"aktuellen Markt-Regime? Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem " + "(3) Was sagt die Backtest-Historie (falls vorhanden) über die Zuverlässigkeit dieser Strategie für dieses Asset aus? " +
"Schema, ohne Text davor oder danach: {\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), " + "(4) Passt das Risk/Reward-Verhältnis zum aktuellen Markt-Regime? " +
"\"thesisSummary\": string, \"invalidationReason\": string, \"keyCatalysts\": string[], " + "Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem Schema, ohne Text davor oder danach: " +
"\"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " + "{\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), \"thesisSummary\": string, \"invalidationReason\": string, " +
"\"keyCatalysts\": string[], \"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " +
"ist günstiger als ein falscher."; "ist günstiger als ein falscher.";
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -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);
}
}
@@ -221,6 +221,7 @@ public class ActiveTradeMonitoringBackgroundService : BackgroundService
return new ActiveTradeDto( return new ActiveTradeDto(
TradeId: e.Id, TradeId: e.Id,
ProposalId: e.ProposalId, ProposalId: e.ProposalId,
UserId: e.UserId,
UnderlyingIsin: e.UnderlyingIsin, UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol, Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin, DerivativeIsin: e.DerivativeIsin,
@@ -889,6 +889,7 @@ public class TradeLifecycleService : ITradeLifecycleService
return new ActiveTradeDto( return new ActiveTradeDto(
TradeId: e.Id, TradeId: e.Id,
ProposalId: e.ProposalId, ProposalId: e.ProposalId,
UserId: e.UserId,
UnderlyingIsin: e.UnderlyingIsin, UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol, Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin, DerivativeIsin: e.DerivativeIsin,
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticTechnicals.Patterns.ChartPatterns;
/// <summary>
/// Detects Double Top (M-reversal) formation where price tests a major resistance peak twice and breaks lower.
/// </summary>
public class DoubleTopDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.DoubleTop;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.Chart;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 25) return null;
var recent = candles.TakeLast(25).ToList();
decimal max1 = decimal.MinValue;
int max1Idx = -1;
decimal max2 = decimal.MinValue;
int max2Idx = -1;
decimal troughBetween = decimal.MaxValue;
// Search for two prominent swing highs
for (int i = 2; i < recent.Count - 2; i++)
{
if (recent[i].High >= recent[i - 1].High && recent[i].High >= recent[i - 2].High &&
recent[i].High >= recent[i + 1].High && recent[i].High >= recent[i + 2].High)
{
if (max1Idx == -1)
{
max1 = recent[i].High;
max1Idx = i;
}
else if (max2Idx == -1 && i > max1Idx + 4)
{
max2 = recent[i].High;
max2Idx = i;
break;
}
}
}
if (max1Idx != -1 && max2Idx != -1)
{
// Calculate trough between the two highs (neckline)
for (int i = max1Idx; i <= max2Idx; i++)
{
if (recent[i].Low < troughBetween) troughBetween = recent[i].Low;
}
decimal priceDifference = Math.Abs(max1 - max2) / max1;
var current = recent.Last();
// Double Top validation: highs within 1.5% of each other, neckline clearly below highs
if (priceDifference <= 0.015m && current.Close <= max2 && troughBetween < max1 * 0.99m)
{
decimal target = troughBetween - (Math.Max(max1, max2) - troughBetween);
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.DoubleTop,
Category: PatternCategory.Chart,
Bias: PatternBias.Bearish,
Name: "Double Top (M-Pattern)",
Timeframe: context.Timeframe,
DetectedAt: current.Timestamp,
KeyPriceLevel: troughBetween,
UpperBoundary: Math.Max(max1, max2),
LowerBoundary: target,
InvalidationLevel: Math.Max(max1, max2) * 1.005m,
QualityScore: 82m,
Description: $"Double top with peaks at {max1:F2} & {max2:F2}, neckline support at {troughBetween:F2}."
);
}
}
return null;
}
}
/// <summary>
/// Detects Inverse Head &amp; Shoulders (bullish reversal) formation.
/// </summary>
public class InverseHeadAndShouldersDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.InverseHeadAndShoulders;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.Chart;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 30) return null;
var recent = candles.TakeLast(30).ToList();
// Look for Left Shoulder Low, Head Low (lowest), Right Shoulder Low
decimal minPrice = recent.Min(c => c.Low);
int headIdx = recent.FindIndex(c => c.Low == minPrice);
if (headIdx >= 5 && headIdx <= recent.Count - 5)
{
decimal leftShoulderLow = recent.Take(headIdx).Min(c => c.Low);
decimal rightShoulderLow = recent.Skip(headIdx + 1).Min(c => c.Low);
// Head must be strictly lower than both shoulders
if (minPrice < leftShoulderLow * 0.99m && minPrice < rightShoulderLow * 0.99m &&
Math.Abs(leftShoulderLow - rightShoulderLow) / leftShoulderLow <= 0.03m)
{
decimal neckline = recent.Skip(headIdx - 3).Take(6).Max(c => c.High);
var current = recent.Last();
if (current.Close >= rightShoulderLow)
{
decimal target = neckline + (neckline - minPrice);
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.InverseHeadAndShoulders,
Category: PatternCategory.Chart,
Bias: PatternBias.Bullish,
Name: "Inverse Head & Shoulders",
Timeframe: context.Timeframe,
DetectedAt: current.Timestamp,
KeyPriceLevel: neckline,
UpperBoundary: target,
LowerBoundary: minPrice,
InvalidationLevel: minPrice * 0.995m,
QualityScore: 85m,
Description: $"Bullish Inverse Head & Shoulders with Head low at {minPrice:F2}, Shoulders ~{leftShoulderLow:F2}, Neckline at {neckline:F2}."
);
}
}
}
return null;
}
}
/// <summary>
/// Detects Descending Triangle (bearish continuation / breakdown) consolidation.
/// </summary>
public class DescendingTriangleDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.DescendingTriangle;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.Chart;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 20) return null;
var recent = candles.TakeLast(20).ToList();
decimal lowSupport = recent.Take(15).Min(c => c.Low);
// Check if lows are flat (horizontal support) while highs are falling (lower highs)
decimal high1 = recent.Take(7).Max(c => c.High);
decimal high2 = recent.Skip(7).Take(7).Max(c => c.High);
decimal high3 = recent.Skip(14).Max(c => c.High);
if (high3 < high2 && high2 < high1 && Math.Abs(recent.Last().Low - lowSupport) / Math.Max(lowSupport, 0.01m) <= 0.01m)
{
var curr = recent.Last();
decimal target = lowSupport - (high1 - lowSupport);
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.DescendingTriangle,
Category: PatternCategory.Chart,
Bias: PatternBias.Bearish,
Name: "Descending Triangle",
Timeframe: context.Timeframe,
DetectedAt: curr.Timestamp,
KeyPriceLevel: lowSupport,
UpperBoundary: high3,
LowerBoundary: target,
InvalidationLevel: high3 * 1.005m,
QualityScore: 80m,
Description: $"Descending triangle with horizontal support at {lowSupport:F2} and descending highs ({high1:F2} -> {high2:F2} -> {high3:F2})."
);
}
return null;
}
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticTechnicals.Patterns.SmartMoney;
/// <summary>
/// Detects institutional Bearish Order Blocks (last bullish candle before a strong downward displacement).
/// </summary>
public class BearishOrderBlockDetector : IPatternDetector
{
/// <inheritdoc />
public PatternType HandledType => PatternType.OrderBlock;
/// <inheritdoc />
public PatternCategory Category => PatternCategory.SmartMoney;
/// <inheritdoc />
public PatternResultDto? Evaluate(TechnicalContext context)
{
var candles = context.PrimaryCandles;
if (candles.Count < 5) return null;
var obCandle = candles[^3];
var impulse1 = candles[^2];
var impulse2 = candles.Last();
// Bearish Order Block: Green candle followed by 2 strong red candles that drop price > 1.5 ATR
if (obCandle.Close > obCandle.Open && impulse1.Close < impulse1.Open && impulse2.Close < impulse2.Open)
{
decimal displacement = obCandle.High - impulse2.Close;
if (displacement >= context.CurrentAtr * 1.5m)
{
return new PatternResultDto(
Id: Guid.NewGuid(),
Type: PatternType.OrderBlock,
Category: PatternCategory.SmartMoney,
Bias: PatternBias.Bearish,
Name: "Bearish Institutional Order Block",
Timeframe: context.Timeframe,
DetectedAt: impulse2.Timestamp,
KeyPriceLevel: (obCandle.Open + obCandle.Close) / 2m,
UpperBoundary: obCandle.High,
LowerBoundary: obCandle.Low,
InvalidationLevel: obCandle.High * 1.005m,
QualityScore: 86m,
Description: $"Bearish order block zone [{obCandle.Low:F2} - {obCandle.High:F2}] with strong downward displacement."
);
}
}
return null;
}
}
+6 -1
View File
@@ -56,6 +56,10 @@ builder.Services.AddSingleton<IPatternDetector, FairValueGapDetector>();
builder.Services.AddSingleton<IPatternDetector, LiquiditySweepDetector>(); builder.Services.AddSingleton<IPatternDetector, LiquiditySweepDetector>();
builder.Services.AddSingleton<IPatternDetector, ChochBosDetector>(); builder.Services.AddSingleton<IPatternDetector, ChochBosDetector>();
builder.Services.AddSingleton<IPatternDetector, OrderBlockDetector>(); builder.Services.AddSingleton<IPatternDetector, OrderBlockDetector>();
builder.Services.AddSingleton<IPatternDetector, DoubleTopDetector>();
builder.Services.AddSingleton<IPatternDetector, InverseHeadAndShouldersDetector>();
builder.Services.AddSingleton<IPatternDetector, DescendingTriangleDetector>();
builder.Services.AddSingleton<IPatternDetector, BearishOrderBlockDetector>();
// 6. Register Strategies // 6. Register Strategies
builder.Services.AddSingleton<ITechnicalStrategy, TrendPullbackFvgStrategy>(); builder.Services.AddSingleton<ITechnicalStrategy, TrendPullbackFvgStrategy>();
@@ -70,7 +74,8 @@ builder.Services.AddSingleton<ITechnicalStrategy, DonchianBreakoutStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, VwapBounceStrategy>(); builder.Services.AddSingleton<ITechnicalStrategy, VwapBounceStrategy>();
// 7. Register Technical Scoring Engine & Universe Manager // 7. Register Technical Scoring Engine & Universe Manager
builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngine>(); // builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngine>(); // V1 Fallback
builder.Services.AddSingleton<ITechnicalScoringEngine, TechnicalScoringEngineV2>(); // V2 Bidirectional Active
builder.Services.AddSingleton<ITechnicalUniverseManager, TechnicalUniverseManager>(); builder.Services.AddSingleton<ITechnicalUniverseManager, TechnicalUniverseManager>();
// 8. Register MQTT Client & RPC Bridge // 8. Register MQTT Client & RPC Bridge
@@ -0,0 +1,611 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticTechnicals.Database;
using FinlyticTechnicals.Entities;
using FinlyticTechnicals.Indicators;
using FinlyticTechnicals.Patterns;
using FinlyticTechnicals.Strategies;
using FinlyticTechnicals.Util;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticTechnicals.Services;
/// <summary>
/// V2 Implementation of <see cref="ITechnicalScoringEngine"/> featuring fully symmetrical,
/// bidirectional (Long &amp; Short) indicator confluence math, direction-aware pattern filtering,
/// and regime-aligned scoring without Long-bias.
/// </summary>
public class TechnicalScoringEngineV2 : ITechnicalScoringEngine
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMultiTimeframeCandleAggregator _aggregator;
private readonly IYahooMarketDataScraper _yahooScraper;
private readonly IEnumerable<IPatternDetector> _patternDetectors;
private readonly IEnumerable<ITechnicalStrategy> _strategies;
private readonly IFinlyticLogger<TechnicalScoringEngineV2> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TechnicalScoringEngineV2"/> class.
/// </summary>
public TechnicalScoringEngineV2(
IServiceScopeFactory scopeFactory,
IMultiTimeframeCandleAggregator aggregator,
IYahooMarketDataScraper yahooScraper,
IEnumerable<IPatternDetector> patternDetectors,
IEnumerable<ITechnicalStrategy> strategies,
IFinlyticLogger<TechnicalScoringEngineV2> logger)
{
_scopeFactory = scopeFactory;
_aggregator = aggregator;
_yahooScraper = yahooScraper;
_patternDetectors = patternDetectors;
_strategies = strategies;
_logger = logger;
}
/// <inheritdoc />
public async Task<List<StrategyResultDto>> AnalyzeIsinAsync(
string isin,
string? symbol = null,
UniverseSource? universeSource = null,
DateTime? universeEnteredAtUtc = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Resolve ticker symbol if needed
string targetSymbol = symbol ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetSymbol))
{
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
// 2. Ensure historical multi-timeframe candles are available in ring buffers
var candles15m = _aggregator.GetCandles(cleanIsin, "15m");
var candles1h = _aggregator.GetCandles(cleanIsin, "1h");
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
if (candles1d.Count < 20 || candles15m.Count < 10)
{
// Backfill deep history from Yahoo
var dailyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "1y", "1d", cancellationToken);
if (dailyRes.Count > 0)
{
var dailyDtos = dailyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "1d", dailyDtos);
}
var hourlyRes = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "60d", "1h", cancellationToken);
if (hourlyRes.Count > 0)
{
var hourlyDtos = hourlyRes.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "1h", hourlyDtos);
}
var min15Res = await _yahooScraper.FetchHistoricalCandlesAsync(targetSymbol, "10d", "15m", cancellationToken);
if (min15Res.Count > 0)
{
var min15Dtos = min15Res.Select(c => new CandleDto(c.Timestamp, c.Open, c.High, c.Low, c.Close, c.Volume)).ToList();
_aggregator.InitializeHistory(cleanIsin, "15m", min15Dtos);
}
}
var allTimeframes = _aggregator.GetAllTimeframes(cleanIsin);
var primaryCandles = _aggregator.GetCandles(cleanIsin, "15m");
if (primaryCandles.Count == 0)
{
primaryCandles = _aggregator.GetCandles(cleanIsin, "1d");
}
if (primaryCandles.Count < 5)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalScoringEngineV2] Insufficient candles for ISIN {Isin}", cleanIsin);
return [];
}
var lastCandle = primaryCandles.Last();
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
// Determine Market Regime symmetrically
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
if (adx.IsTrending)
{
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
}
else if (currentAtr > (lastCandle.Close * 0.03m))
{
regime = MarketRegime.HighVolatilityChoppy;
}
// Build TechnicalContext
var indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
["ATR_14"] = currentAtr,
["ADX_14"] = adx.Adx,
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
};
var context = new TechnicalContext
{
Isin = cleanIsin,
Symbol = targetSymbol,
Timeframe = "15m",
TimestampUtc = lastCandle.Timestamp,
CurrentPrice = lastCandle.Close,
CurrentSpread = 0m,
IsSpreadVolatile = false,
CurrentAtr = currentAtr,
Regime = regime,
MultiTimeframeCandles = allTimeframes,
Indicators = indicators
};
// 3. Run all Pattern Detectors
var detectedPatterns = new List<PatternResultDto>();
foreach (var detector in _patternDetectors)
{
try
{
var pat = detector.Evaluate(context);
if (pat != null)
{
detectedPatterns.Add(pat);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScoringEngineV2] Pattern detector {Detector} threw an exception for ISIN {Isin}", detector.GetType().Name, cleanIsin);
}
}
// 4. Run all Strategies
var evaluatedSetups = new List<StrategyResultDto>();
foreach (var strategy in _strategies.OrderBy(s => s.Priority))
{
try
{
if (!strategy.IsApplicable(regime)) continue;
var setup = strategy.Evaluate(context, detectedPatterns);
if (setup != null)
{
// Confluence Scoring Calculation:
// FinalScore = 0.35 * S_ind + 0.35 * S_pattern + 0.30 * S_strat
decimal indicatorScore = CalculateIndicatorConfluenceScoreV2(indicators, setup.Direction, lastCandle.Close);
decimal patternScore = CalculateDirectionalPatternScore(detectedPatterns, setup.Direction);
decimal strategyBaseScore = setup.QualityScore;
decimal finalScore = (0.35m * indicatorScore) + (0.35m * patternScore) + (0.30m * strategyBaseScore);
finalScore = Math.Clamp(finalScore, 0m, 100m);
bool isTopPick = finalScore >= 75.0m;
string rating = finalScore >= 85.0m ? "A+" :
finalScore >= 75.0m ? "A" :
finalScore >= 60.0m ? "B" : "C";
var scoredSetup = setup with
{
QualityScore = finalScore,
IsTopPick = isTopPick,
Rating = rating,
UniverseSource = universeSource,
UniverseEnteredAtUtc = universeEnteredAtUtc,
Regime = regime
};
evaluatedSetups.Add(scoredSetup);
}
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScoringEngineV2] Strategy {Strategy} threw an exception for ISIN {Isin}", strategy.StrategyKey, cleanIsin);
}
}
// 5. Persist Setups and Patterns into PostgreSQL
await PersistResultsAsync(cleanIsin, targetSymbol, detectedPatterns, evaluatedSetups);
return evaluatedSetups;
}
/// <summary>
/// Calculates symmetrical indicator confluence score for both Buy and Sell directions (0..100).
/// </summary>
private static decimal CalculateIndicatorConfluenceScoreV2(Dictionary<string, decimal> ind, SignalDirection dir, decimal currentPrice)
{
decimal score = 50m;
if (dir == SignalDirection.Buy)
{
// Trend alignment: Fast EMA above Slow EMA (+15)
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 > e50) score += 15m;
// Momentum in bullish expansion / pull-back zone (+15)
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m;
// Trend strength confirmation (+10)
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
// Price acceptance above VWAP (+10)
if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && (currentPrice > vwap || (ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap))) score += 10m;
}
else if (dir == SignalDirection.Sell)
{
// Symmetrical Trend alignment: Fast EMA below Slow EMA (+15)
if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > 0m && e50 > 0m && e20 < e50) score += 15m;
// Symmetrical Momentum in bearish breakdown / relief-rally zone (+15)
if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m;
// Trend strength confirmation (+10)
if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m;
// Symmetrical Price rejection below VWAP (+10) -> enables full 100 points for Sell!
if (ind.TryGetValue("VWAP", out var vwap) && vwap > 0m && (currentPrice < vwap || (ind.TryGetValue("EMA_20", out var e20b) && e20b > 0m && e20b < vwap))) score += 10m;
}
return Math.Clamp(score, 0m, 100m);
}
/// <summary>
/// Evaluates detected patterns considering directional bias alignment with the setup.
/// </summary>
private static decimal CalculateDirectionalPatternScore(IReadOnlyList<PatternResultDto> patterns, SignalDirection direction)
{
if (patterns.Count == 0) return 50m;
var expectedBias = direction == SignalDirection.Buy ? PatternBias.Bullish : PatternBias.Bearish;
var opposingBias = direction == SignalDirection.Buy ? PatternBias.Bearish : PatternBias.Bullish;
var matchingPatterns = patterns.Where(p => p.Bias == expectedBias || p.Bias == PatternBias.Neutral).ToList();
var opposingPatterns = patterns.Where(p => p.Bias == opposingBias).ToList();
if (matchingPatterns.Count == 0 && opposingPatterns.Count > 0)
{
// Conflicting patterns penalize the score
return Math.Max(30m, 50m - (opposingPatterns.Count * 10m));
}
if (matchingPatterns.Count > 0)
{
decimal avgQuality = matchingPatterns.Average(p => p.QualityScore);
// Deduct minor penalty if conflicting patterns also exist
decimal penalty = opposingPatterns.Count * 5m;
return Math.Clamp(avgQuality - penalty, 0m, 100m);
}
return 50m;
}
/// <inheritdoc />
public async Task<TechnicalAnalysisDto?> GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim().ToUpperInvariant();
// 1. Resolve ticker symbol if needed
string targetSymbol = symbol ?? string.Empty;
if (string.IsNullOrWhiteSpace(targetSymbol))
{
targetSymbol = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
}
// 2. Ensure historical multi-timeframe candles & setups are calculated
var evaluatedSetups = await AnalyzeIsinAsync(cleanIsin, targetSymbol, cancellationToken: cancellationToken);
var candles1d = _aggregator.GetCandles(cleanIsin, "1d");
var primaryCandles = candles1d.Count > 0 ? candles1d : _aggregator.GetCandles(cleanIsin, "15m");
if (primaryCandles.Count == 0)
{
primaryCandles = _aggregator.GetCandles(cleanIsin, "1h");
}
if (primaryCandles.Count == 0)
{
return null;
}
var lastCandle = primaryCandles.Last();
decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(primaryCandles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(primaryCandles, 14);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 20);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 50);
MarketRegime regime = MarketRegime.LowVolatilityRangebound;
if (adx.IsTrending)
{
regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
}
else if (currentAtr > (lastCandle.Close * 0.03m))
{
regime = MarketRegime.HighVolatilityChoppy;
}
var context = new TechnicalContext
{
Isin = cleanIsin,
Symbol = targetSymbol,
Timeframe = "1d",
TimestampUtc = lastCandle.Timestamp,
CurrentPrice = lastCandle.Close,
CurrentSpread = 0m,
IsSpreadVolatile = false,
CurrentAtr = currentAtr,
Regime = regime,
MultiTimeframeCandles = _aggregator.GetAllTimeframes(cleanIsin),
Indicators = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(primaryCandles, 200),
["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(primaryCandles, 14),
["ATR_14"] = currentAtr,
["ADX_14"] = adx.Adx,
["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(primaryCandles)
}
};
var detectedPatterns = new List<PatternResultDto>();
foreach (var detector in _patternDetectors)
{
try
{
var pat = detector.Evaluate(context);
if (pat != null)
{
detectedPatterns.Add(pat);
}
}
catch { }
}
var indicatorList = new List<IndicatorValuesDto>();
var candlesList = primaryCandles.ToList();
for (int i = 0; i < candlesList.Count; i++)
{
var slice = candlesList.Take(i + 1).ToList();
var c = candlesList[i];
var macd = TechnicalIndicatorsEngine.CalculateMacd(slice);
var st = TechnicalIndicatorsEngine.CalculateSuperTrend(slice);
var atr = TechnicalIndicatorsEngine.CalculateAtr(slice, 14);
indicatorList.Add(new IndicatorValuesDto(
Timestamp: c.Timestamp,
Ema20: TechnicalIndicatorsEngine.CalculateEma(slice, 20),
Sma50: TechnicalIndicatorsEngine.CalculateSma(slice, 50),
Sma200: TechnicalIndicatorsEngine.CalculateSma(slice, 200),
Rsi14: TechnicalIndicatorsEngine.CalculateRsi(slice, 14),
MacdLine: macd.MacdLine,
MacdSignal: macd.SignalLine,
MacdHistogram: macd.Histogram,
Atr14: atr,
Vwap: TechnicalIndicatorsEngine.CalculateVwap(slice),
SupertrendUpper: st.Direction == SignalDirection.Sell ? st.Value : null,
SupertrendLower: st.Direction == SignalDirection.Buy ? st.Value : null,
SupertrendDirection: st.Direction.ToString().ToUpperInvariant(),
RecommendedStopLoss: c.Close - (atr * 2m)
));
}
var chartPatterns = detectedPatterns.Select(p => new ChartPatternDto(
Type: p.Type.ToString(),
Description: p.Description,
UpperLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High), new(lastCandle.Timestamp, p.UpperBoundary > 0m ? p.UpperBoundary : lastCandle.High) },
LowerLine: new List<PatternPointDto> { new(lastCandle.Timestamp.AddDays(-5), p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low), new(lastCandle.Timestamp, p.LowerBoundary > 0m ? p.LowerBoundary : lastCandle.Low) },
ApexTime: lastCandle.Timestamp,
BreakoutSignal: new BreakoutSignalDto(lastCandle.Timestamp, p.Bias.ToString().ToUpperInvariant(), p.KeyPriceLevel > 0m ? p.KeyPriceLevel : lastCandle.Close, p.KeyPriceLevel > 0m ? p.KeyPriceLevel * 1.05m : lastCandle.Close * 1.05m, 5.0m),
ConfidencePercent: p.QualityScore
)).ToList();
var strategySignals = evaluatedSetups.Select(s => new StrategySignalDto(
Type: s.StrategyKey,
Timestamp: s.CreatedAt,
Direction: s.Direction.ToString().ToUpperInvariant(),
Price: s.CurrentPrice,
Description: s.TechnicalRationale
)).ToList();
var marketRegimeDto = new MarketRegimeDto(
VixValue: 18.5m,
VixRegime: regime.ToString(),
MarketTrend: regime == MarketRegime.BullishTrending ? "Bullish" : regime == MarketRegime.BearishTrending ? "Bearish" : "Neutral",
DxyValue: 104.2m,
DxyState: "Neutral",
SummaryText: $"Market Regime: {regime} with ATR {currentAtr:F2}"
);
return new TechnicalAnalysisDto(
Isin: cleanIsin,
Ticker: targetSymbol,
CompanyName: targetSymbol,
LastUpdated: lastCandle.Timestamp,
Candles: candlesList,
Indicators: indicatorList,
Patterns: chartPatterns,
Signals: strategySignals,
MarketRegime: marketRegimeDto,
Currency: "EUR"
);
}
/// <inheritdoc />
public async Task<List<StrategyResultDto>> GetActiveSetupsAsync(
bool topPicksOnly = false,
int limit = 50,
decimal? minScore = null,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var now = DateTime.UtcNow;
var query = db.FtaTechnicalSetups.AsNoTracking()
.Where(s => s.IsActive && s.ExpiresAtUtc > now);
if (topPicksOnly)
{
query = query.Where(s => s.IsTopPick);
}
if (minScore.HasValue)
{
query = query.Where(s => s.QualityScore >= minScore.Value);
}
var entities = await query
.OrderByDescending(s => s.QualityScore)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(MapEntityToDto).ToList();
}
/// <inheritdoc />
public async Task<List<StrategyResultDto>> GetRecentSetupHistoryAsync(
string isin,
int limit = 8,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return [];
var cleanIsin = isin.Trim().ToUpperInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
var entities = await db.FtaTechnicalSetups.AsNoTracking()
.Where(s => s.Isin == cleanIsin)
.OrderByDescending(s => s.CreatedAtUtc)
.Take(Math.Max(1, limit))
.ToListAsync(cancellationToken);
return entities.Select(MapEntityToDto).ToList();
}
private async Task PersistResultsAsync(string isin, string symbol, List<PatternResultDto> patterns, List<StrategyResultDto> setups)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TechnicalAnalysisDbContext>();
// Save detected patterns
foreach (var pat in patterns)
{
db.FtaDetectedPatterns.Add(new FtaDetectedPatternEntity
{
Id = pat.Id,
Isin = isin,
Timeframe = pat.Timeframe,
PatternType = pat.Type.ToString(),
Category = pat.Category.ToString(),
Bias = pat.Bias.ToString(),
Name = pat.Name,
KeyPriceLevel = pat.KeyPriceLevel,
UpperBoundary = pat.UpperBoundary,
LowerBoundary = pat.LowerBoundary,
InvalidationLevel = pat.InvalidationLevel,
QualityScore = pat.QualityScore,
Description = pat.Description,
ExtraData = pat.ExtraData,
DetectedAtUtc = pat.DetectedAt
});
}
// Save strategy setups
foreach (var setup in setups)
{
db.FtaTechnicalSetups.Add(new FtaTechnicalSetupEntity
{
SetupId = setup.SetupId,
Isin = isin,
Symbol = symbol,
Timeframe = setup.Timeframe,
StrategyKey = setup.StrategyKey,
StrategyName = setup.StrategyName,
Direction = setup.Direction.ToString(),
QualityScore = setup.QualityScore,
CurrentPrice = setup.CurrentPrice,
EntryPrice = setup.EntryPrice,
InvalidationPrice = setup.InvalidationPrice,
CurrentAtr = setup.CurrentAtr,
EstimatedRiskRewardRatio = setup.EstimatedRiskRewardRatio,
ExitPlan = setup.ExitPlan,
TechnicalRationale = setup.TechnicalRationale,
TriggeringPatterns = setup.TriggeringPatterns,
IndicatorSnapshot = setup.IndicatorSnapshot,
IsTopPick = setup.IsTopPick,
Rating = setup.Rating,
IsActive = true,
CreatedAtUtc = setup.CreatedAt,
ExpiresAtUtc = setup.ExpiresAt,
UniverseSource = setup.UniverseSource?.ToString(),
UniverseEnteredAtUtc = setup.UniverseEnteredAtUtc,
Regime = setup.Regime?.ToString()
});
}
await db.SaveChangesAsync();
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex,
"[TechnicalScoringEngineV2] Failed to persist technical setups for ISIN {Isin}", isin);
}
}
private static StrategyResultDto MapEntityToDto(FtaTechnicalSetupEntity entity)
{
var direction = Enum.TryParse<SignalDirection>(entity.Direction, true, out var dir) ? dir : SignalDirection.Buy;
UniverseSource? universeSource = !string.IsNullOrWhiteSpace(entity.UniverseSource) &&
Enum.TryParse<UniverseSource>(entity.UniverseSource, true, out var src)
? src
: null;
MarketRegime? regime = !string.IsNullOrWhiteSpace(entity.Regime) &&
Enum.TryParse<MarketRegime>(entity.Regime, true, out var reg)
? reg
: null;
return new StrategyResultDto(
SetupId: entity.SetupId,
Isin: entity.Isin,
Symbol: entity.Symbol,
Timeframe: entity.Timeframe,
StrategyKey: entity.StrategyKey,
StrategyName: entity.StrategyName,
Direction: direction,
QualityScore: entity.QualityScore,
CurrentPrice: entity.CurrentPrice,
EntryPrice: entity.EntryPrice,
InvalidationPrice: entity.InvalidationPrice,
CurrentAtr: entity.CurrentAtr,
EstimatedRiskRewardRatio: entity.EstimatedRiskRewardRatio,
ExitPlan: entity.ExitPlan ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, entity.InvalidationPrice, []),
TechnicalRationale: entity.TechnicalRationale,
TriggeringPatterns: entity.TriggeringPatterns ?? [],
IndicatorSnapshot: entity.IndicatorSnapshot ?? new Dictionary<string, decimal>(),
CreatedAt: entity.CreatedAtUtc,
ExpiresAt: entity.ExpiresAtUtc,
IsTopPick: entity.IsTopPick,
Rating: entity.Rating,
UniverseSource: universeSource,
UniverseEnteredAtUtc: entity.UniverseEnteredAtUtc,
Regime: regime
);
}
}