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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user