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,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, ChochBosDetector>();
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
builder.Services.AddSingleton<ITechnicalStrategy, TrendPullbackFvgStrategy>();
@@ -70,7 +74,8 @@ builder.Services.AddSingleton<ITechnicalStrategy, DonchianBreakoutStrategy>();
builder.Services.AddSingleton<ITechnicalStrategy, VwapBounceStrategy>();
// 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>();
// 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
);
}
}