feat(technicals,engine): add V2 multi-timeframe scoring, SMC patterns, and COS V2 engine
This commit is contained in:
@@ -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