From f43ce2b7e97f4b1246d53f6f7d7d63b137fdae1b Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Mon, 24 Aug 2026 21:36:05 +0200 Subject: [PATCH] feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies --- .../Database/TechnicalAnalysisDbContext.cs | 129 ++ FinlyticTechnicals/Dockerfile | 22 + .../Entities/FtaCandleEntity.cs | 47 + .../Entities/FtaDetectedPatternEntity.cs | 55 + .../FtaMonitoredUniverseAssetEntity.cs | 37 + .../Entities/FtaTechnicalSetupEntity.cs | 90 ++ FinlyticTechnicals/FinlyticTechnicals.csproj | 31 + .../Indicators/CandleResampler.cs | 78 ++ .../Indicators/TechnicalIndicatorsEngine.cs | 317 +++++ .../20260819182054_Init.Designer.cs | 289 +++++ .../Migrations/20260819182054_Init.cs | 178 +++ ...53627_SyncTechnicalsModelDrift.Designer.cs | 291 +++++ ...20260821153627_SyncTechnicalsModelDrift.cs | 27 + ...1353_AddMonitoredUniverseTable.Designer.cs | 331 +++++ ...0260822081353_AddMonitoredUniverseTable.cs | 69 + ...549_AddRegimeToTechnicalSetups.Designer.cs | 335 +++++ ...260822090549_AddRegimeToTechnicalSetups.cs | 29 + ...TechnicalAnalysisDbContextModelSnapshot.cs | 332 +++++ .../CandlestickPatternDetectors.cs | 250 ++++ .../ChartPatterns/ChartPatternDetectors.cs | 185 +++ .../Patterns/IPatternDetector.cs | 17 + .../SmartMoney/SmcPatternDetectors.cs | 250 ++++ FinlyticTechnicals/Program.cs | 110 ++ .../Services/ITAMqttRpcClient.cs | 16 + .../MultiTimeframeCandleAggregator.cs | 245 ++++ .../TechnicalScannerBackgroundService.cs | 118 ++ .../Services/TechnicalScoringEngine.cs | 580 +++++++++ .../Services/TechnicalUniverseManager.cs | 283 +++++ .../Services/TradeRepublicIngestionService.cs | 148 +++ .../Services/YahooMarketDataScraper.cs | 213 ++++ .../Strategies/CoreStrategies.cs | 1122 +++++++++++++++++ .../Strategies/ITechnicalStrategy.cs | 24 + .../Timeframe/CircularRingBuffer.cs | 228 ++++ FinlyticTechnicals/Util/SettingKeys.cs | 26 + FinlyticTechnicals/Util/TAMqttClient.cs | 271 ++++ FinlyticTechnicals/appsettings.json | 19 + 36 files changed, 6792 insertions(+) create mode 100644 FinlyticTechnicals/Database/TechnicalAnalysisDbContext.cs create mode 100644 FinlyticTechnicals/Dockerfile create mode 100644 FinlyticTechnicals/Entities/FtaCandleEntity.cs create mode 100644 FinlyticTechnicals/Entities/FtaDetectedPatternEntity.cs create mode 100644 FinlyticTechnicals/Entities/FtaMonitoredUniverseAssetEntity.cs create mode 100644 FinlyticTechnicals/Entities/FtaTechnicalSetupEntity.cs create mode 100644 FinlyticTechnicals/FinlyticTechnicals.csproj create mode 100644 FinlyticTechnicals/Indicators/CandleResampler.cs create mode 100644 FinlyticTechnicals/Indicators/TechnicalIndicatorsEngine.cs create mode 100644 FinlyticTechnicals/Migrations/20260819182054_Init.Designer.cs create mode 100644 FinlyticTechnicals/Migrations/20260819182054_Init.cs create mode 100644 FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.Designer.cs create mode 100644 FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.cs create mode 100644 FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.Designer.cs create mode 100644 FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.cs create mode 100644 FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.Designer.cs create mode 100644 FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.cs create mode 100644 FinlyticTechnicals/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs create mode 100644 FinlyticTechnicals/Patterns/Candlesticks/CandlestickPatternDetectors.cs create mode 100644 FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectors.cs create mode 100644 FinlyticTechnicals/Patterns/IPatternDetector.cs create mode 100644 FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectors.cs create mode 100644 FinlyticTechnicals/Program.cs create mode 100644 FinlyticTechnicals/Services/ITAMqttRpcClient.cs create mode 100644 FinlyticTechnicals/Services/MultiTimeframeCandleAggregator.cs create mode 100644 FinlyticTechnicals/Services/TechnicalScannerBackgroundService.cs create mode 100644 FinlyticTechnicals/Services/TechnicalScoringEngine.cs create mode 100644 FinlyticTechnicals/Services/TechnicalUniverseManager.cs create mode 100644 FinlyticTechnicals/Services/TradeRepublicIngestionService.cs create mode 100644 FinlyticTechnicals/Services/YahooMarketDataScraper.cs create mode 100644 FinlyticTechnicals/Strategies/CoreStrategies.cs create mode 100644 FinlyticTechnicals/Strategies/ITechnicalStrategy.cs create mode 100644 FinlyticTechnicals/Timeframe/CircularRingBuffer.cs create mode 100644 FinlyticTechnicals/Util/SettingKeys.cs create mode 100644 FinlyticTechnicals/Util/TAMqttClient.cs create mode 100644 FinlyticTechnicals/appsettings.json diff --git a/FinlyticTechnicals/Database/TechnicalAnalysisDbContext.cs b/FinlyticTechnicals/Database/TechnicalAnalysisDbContext.cs new file mode 100644 index 0000000..281f102 --- /dev/null +++ b/FinlyticTechnicals/Database/TechnicalAnalysisDbContext.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using FinlyticCore.Database; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Entities.Settings; +using FinlyticTechnicals.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FinlyticTechnicals.Database; + +public class TechnicalAnalysisDbContext : DbContext, ISettingsDbContext +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public TechnicalAnalysisDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet DynamicSettings => Set(); + public DbSet FtaCandles => Set(); + public DbSet FtaTechnicalSetups => Set(); + public DbSet FtaDetectedPatterns => Set(); + public DbSet MonitoredUniverseAssets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // 1. Settings Table + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Key).IsUnique(); + }); + + // 2. FTA Candles Table & Composite Time-Series Index + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.Isin, e.Timeframe, e.TimestampUtc }); + entity.HasIndex(e => e.TimestampUtc); + }); + + // 3. JSONB Value Converters for Complex Types + var exitPlanConverter = new ValueConverter( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List(), null, null, null, null) + ); + + var patternsConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions) ?? new List() + ); + + var indicatorSnapshotConverter = new ValueConverter, string>( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions) ?? new Dictionary() + ); + + var extraDataConverter = new ValueConverter?, string>( + v => v == null ? "{}" : JsonSerializer.Serialize(v, JsonOptions), + v => string.IsNullOrWhiteSpace(v) ? null : JsonSerializer.Deserialize>(v, JsonOptions) + ); + + // 4. FTA Technical Setups Table & JSONB mappings + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SetupId); + entity.HasIndex(e => new { e.Isin, e.IsActive, e.ExpiresAtUtc }); + entity.HasIndex(e => e.CreatedAtUtc); + entity.HasIndex(e => e.QualityScore); + entity.HasIndex(e => e.IsTopPick); + entity.HasIndex(e => new { e.IsActive, e.IsTopPick, e.QualityScore }); + + + entity.Property(e => e.ExitPlan) + .HasColumnType("jsonb") + .HasConversion(exitPlanConverter); + + entity.Property(e => e.TriggeringPatterns) + .HasColumnType("jsonb") + .HasConversion(patternsConverter); + + entity.Property(e => e.IndicatorSnapshot) + .HasColumnType("jsonb") + .HasConversion(indicatorSnapshotConverter); + }); + + // 5. FTA Detected Patterns Table & JSONB mappings + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.Isin, e.DetectedAtUtc }); + entity.HasIndex(e => e.PatternType); + entity.HasIndex(e => e.QualityScore); + + entity.Property(e => e.ExtraData) + .HasColumnType("jsonb") + .HasConversion(extraDataConverter); + }); + + // 6. Monitored Universe Assets Table (persisted backing store for TechnicalUniverseManager, cleared on + // every service startup - see FtaMonitoredUniverseAssetEntity's doc comment) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Isin); + entity.HasIndex(e => e.Source); + entity.HasIndex(e => e.ExpiresAtUtc); + }); + } +} + +public class TechnicalAnalysisDbContextFactory : IDesignTimeDbContextFactory +{ + public TechnicalAnalysisDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_ta;Username=postgres;Password=postgres"); + return new TechnicalAnalysisDbContext(optionsBuilder.Options); + } +} diff --git a/FinlyticTechnicals/Dockerfile b/FinlyticTechnicals/Dockerfile new file mode 100644 index 0000000..abc80cd --- /dev/null +++ b/FinlyticTechnicals/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FinlyticTechnicals/FinlyticTechnicals.csproj", "FinlyticTechnicals/"] +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +RUN dotnet restore "FinlyticTechnicals/FinlyticTechnicals.csproj" +COPY . . +WORKDIR "/src/FinlyticTechnicals" +RUN dotnet build "./FinlyticTechnicals.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FinlyticTechnicals.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticTechnicals.dll"] diff --git a/FinlyticTechnicals/Entities/FtaCandleEntity.cs b/FinlyticTechnicals/Entities/FtaCandleEntity.cs new file mode 100644 index 0000000..6143b7f --- /dev/null +++ b/FinlyticTechnicals/Entities/FtaCandleEntity.cs @@ -0,0 +1,47 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticTechnicals.Entities; + +[Table("fta_candles")] +public class FtaCandleEntity +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Required] + [MaxLength(20)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(30)] + public string Symbol { get; set; } = string.Empty; + + [Required] + [MaxLength(10)] + public string Timeframe { get; set; } = "15m"; + + [Required] + public DateTime TimestampUtc { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal Open { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal High { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal Low { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal Close { get; set; } + + public long Volume { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? Bid { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? Ask { get; set; } +} diff --git a/FinlyticTechnicals/Entities/FtaDetectedPatternEntity.cs b/FinlyticTechnicals/Entities/FtaDetectedPatternEntity.cs new file mode 100644 index 0000000..253c7c1 --- /dev/null +++ b/FinlyticTechnicals/Entities/FtaDetectedPatternEntity.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticTechnicals.Entities; + +[Table("fta_detected_patterns")] +public class FtaDetectedPatternEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(20)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(10)] + public string Timeframe { get; set; } = "15m"; + + [Required] + [MaxLength(50)] + public string PatternType { get; set; } = string.Empty; + + [MaxLength(30)] + public string Category { get; set; } = string.Empty; + + [MaxLength(20)] + public string Bias { get; set; } = "Neutral"; + + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [Column(TypeName = "decimal(18,4)")] + public decimal KeyPriceLevel { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal UpperBoundary { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal LowerBoundary { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal InvalidationLevel { get; set; } + + [Column(TypeName = "decimal(6,2)")] + public decimal QualityScore { get; set; } + + public string Description { get; set; } = string.Empty; + + public Dictionary? ExtraData { get; set; } + + [Required] + public DateTime DetectedAtUtc { get; set; } +} diff --git a/FinlyticTechnicals/Entities/FtaMonitoredUniverseAssetEntity.cs b/FinlyticTechnicals/Entities/FtaMonitoredUniverseAssetEntity.cs new file mode 100644 index 0000000..d2f1c35 --- /dev/null +++ b/FinlyticTechnicals/Entities/FtaMonitoredUniverseAssetEntity.cs @@ -0,0 +1,37 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticTechnicals.Entities; + +/// +/// Persisted backing store for TechnicalUniverseManager's continuously-scanned asset universe (one row +/// per monitored ISIN). Deliberately NOT meant to survive a service restart - Program.cs clears this +/// table on every startup, since the universe is fully rebuilt within minutes from +/// RefreshFavoritesAsync/RefreshDiscoveryAsync and fresh sentiment-spike events, and a stale row +/// that never got TTL-swept because the process was down is worse than starting from an empty universe. +/// +[Table("fta_monitored_universe_assets")] +public class FtaMonitoredUniverseAssetEntity +{ + [Key] + [MaxLength(20)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(30)] + public string? Symbol { get; set; } + + /// String form of FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource. + [Required] + [MaxLength(20)] + public string Source { get; set; } = string.Empty; + + /// Lower value = higher scan priority (SentimentSpike=1, UserFavorite=2, Discovery=3). + public int Priority { get; set; } + + [Required] + public DateTime AddedAtUtc { get; set; } = DateTime.UtcNow; + + /// for favorites/discovery entries, which never expire by TTL. + public DateTime? ExpiresAtUtc { get; set; } +} diff --git a/FinlyticTechnicals/Entities/FtaTechnicalSetupEntity.cs b/FinlyticTechnicals/Entities/FtaTechnicalSetupEntity.cs new file mode 100644 index 0000000..0bf70de --- /dev/null +++ b/FinlyticTechnicals/Entities/FtaTechnicalSetupEntity.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Entities; + +[Table("fta_technical_setups")] +public class FtaTechnicalSetupEntity +{ + [Key] + public Guid SetupId { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(20)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(30)] + public string Symbol { get; set; } = string.Empty; + + [Required] + [MaxLength(10)] + public string Timeframe { get; set; } = "15m"; + + [Required] + [MaxLength(50)] + public string StrategyKey { get; set; } = string.Empty; + + [MaxLength(100)] + public string StrategyName { get; set; } = string.Empty; + + [MaxLength(10)] + public string Direction { get; set; } = "Buy"; + + [Column(TypeName = "decimal(6,2)")] + public decimal QualityScore { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal CurrentPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal EntryPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal InvalidationPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal CurrentAtr { get; set; } + + [Column(TypeName = "decimal(8,2)")] + public decimal EstimatedRiskRewardRatio { get; set; } + + public ExitPlan ExitPlan { get; set; } = null!; + + public string TechnicalRationale { get; set; } = string.Empty; + + public List TriggeringPatterns { get; set; } = []; + + public Dictionary IndicatorSnapshot { get; set; } = []; + + public bool IsTopPick { get; set; } + + [MaxLength(5)] + public string Rating { get; set; } = "B"; + + public bool IsActive { get; set; } = true; + + [Required] + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime ExpiresAtUtc { get; set; } + + /// + /// String form of the UniverseSource this ISIN was being monitored under when this setup was + /// computed (favorite/discovery/sentiment-spike), or for an ad hoc analysis (e.g. a + /// manual "Analyze now" call for an ISIN not currently in the scan universe). See + /// FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto.UniverseSource. + /// + [MaxLength(20)] + public string? UniverseSource { get; set; } + + /// When the ISIN above entered that scan universe, alongside . + public DateTime? UniverseEnteredAtUtc { get; set; } + + /// String form of the MarketRegime at analysis time. See StrategyResultDto.Regime. + [MaxLength(30)] + public string? Regime { get; set; } +} diff --git a/FinlyticTechnicals/FinlyticTechnicals.csproj b/FinlyticTechnicals/FinlyticTechnicals.csproj new file mode 100644 index 0000000..4c7f8d9 --- /dev/null +++ b/FinlyticTechnicals/FinlyticTechnicals.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + Linux + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/FinlyticTechnicals/Indicators/CandleResampler.cs b/FinlyticTechnicals/Indicators/CandleResampler.cs new file mode 100644 index 0000000..5b8ad2b --- /dev/null +++ b/FinlyticTechnicals/Indicators/CandleResampler.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Indicators; + +/// +/// Standard OHLCV rollup of a finer-grained, chronologically ordered candle series into coarser buckets +/// (first Open, max High, min Low, last Close, summed Volume). Shared by the live ring-buffer aggregator +/// (MultiTimeframeCandleAggregator, which previously duplicated this exact bucketing logic per +/// timeframe) and backtest replay (FinlyticSimulation.Engine.HistoricalReplayRunner, which previously +/// had no way to derive a higher timeframe at all - see its own doc comment) so both paths compute higher +/// timeframes identically instead of maintaining two separate implementations. +/// +public static class CandleResampler +{ + /// Bucket size in minutes for every timeframe name known across FinlyticTechnicals/FinlyticSimulation. + public static readonly IReadOnlyDictionary KnownTimeframeMinutes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["1m"] = 1, + ["5m"] = 5, + ["15m"] = 15, + ["1h"] = 60, + ["1d"] = 1440 + }; + + /// Every known timeframe strictly coarser than , ascending. + public static IEnumerable<(string Timeframe, int Minutes)> CoarserTimeframes(int baseMinutes) => + KnownTimeframeMinutes + .Where(kv => kv.Value > baseMinutes) + .OrderBy(kv => kv.Value) + .Select(kv => (kv.Key, kv.Value)); + + /// + /// Aggregates into -wide bars. Returns an empty + /// list (never fabricates a partial/synthetic bar) if is empty. + /// + public static List Resample(IReadOnlyList source, int bucketMinutes) + { + if (source.Count == 0 || bucketMinutes <= 0) return []; + + var groups = source + .GroupBy(c => BucketStart(c.Timestamp, bucketMinutes)) + .OrderBy(g => g.Key); + + var result = new List(); + foreach (var group in groups) + { + var bars = group.OrderBy(b => b.Timestamp).ToList(); + if (bars.Count == 0) continue; + + result.Add(new CandleDto( + Timestamp: group.Key, + Open: bars[0].Open, + High: bars.Max(b => b.High), + Low: bars.Min(b => b.Low), + Close: bars[^1].Close, + Volume: bars.Sum(b => b.Volume), + Bid: bars[^1].Bid, + Ask: bars[^1].Ask + )); + } + + return result; + } + + private static DateTime BucketStart(DateTime timestamp, int bucketMinutes) + { + var dayStart = new DateTime(timestamp.Year, timestamp.Month, timestamp.Day, 0, 0, 0, DateTimeKind.Utc); + if (bucketMinutes >= 1440) return dayStart; + + int totalMinutes = timestamp.Hour * 60 + timestamp.Minute; + int bucketed = (totalMinutes / bucketMinutes) * bucketMinutes; + return dayStart.AddMinutes(bucketed); + } +} diff --git a/FinlyticTechnicals/Indicators/TechnicalIndicatorsEngine.cs b/FinlyticTechnicals/Indicators/TechnicalIndicatorsEngine.cs new file mode 100644 index 0000000..9c6b5b0 --- /dev/null +++ b/FinlyticTechnicals/Indicators/TechnicalIndicatorsEngine.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Indicators; + +public record MacdResult( + decimal MacdLine, + decimal SignalLine, + decimal Histogram +); + +public record BollingerBandsResult( + decimal UpperBand, + decimal MiddleBand, + decimal LowerBand, + decimal Bandwidth, + decimal PercentB +); + +public record KeltnerChannelResult( + decimal UpperBand, + decimal MiddleBand, + decimal LowerBand +); + +public record SuperTrendResult( + decimal Value, + SignalDirection Direction, + bool IsFlipped +); + +public record SqueezeResult( + bool IsInSqueeze, + decimal MomentumHistogram, + string SqueezeState // "ON", "FIRED_BULLISH", "FIRED_BEARISH", "NONE" +); + +public record AdxResult( + decimal Adx, + decimal PlusDi, + decimal MinusDi, + bool IsTrending +); + +/// +/// High-performance mathematical indicators engine for time-series analysis. +/// +public static class TechnicalIndicatorsEngine +{ + public static decimal CalculateSma(IReadOnlyList candles, int period) + { + if (candles == null || candles.Count < period || period <= 0) return 0m; + decimal sum = 0m; + for (int i = candles.Count - period; i < candles.Count; i++) + { + sum += candles[i].Close; + } + return sum / period; + } + + public static decimal CalculateEma(IReadOnlyList candles, int period) + { + if (candles == null || candles.Count == 0 || period <= 0) return 0m; + if (candles.Count < period) return CalculateSma(candles, candles.Count); + + decimal k = 2m / (period + 1); + // Seed with SMA + decimal ema = 0m; + for (int i = 0; i < period; i++) + { + ema += candles[i].Close; + } + ema /= period; + + for (int i = period; i < candles.Count; i++) + { + ema = (candles[i].Close * k) + (ema * (1m - k)); + } + return ema; + } + + public static decimal CalculateRsi(IReadOnlyList candles, int period = 14) + { + if (candles == null || candles.Count <= period || period <= 0) return 50m; + + decimal gains = 0m; + decimal losses = 0m; + + for (int i = 1; i <= period; i++) + { + decimal diff = candles[i].Close - candles[i - 1].Close; + if (diff >= 0) gains += diff; + else losses += Math.Abs(diff); + } + + decimal avgGain = gains / period; + decimal avgLoss = losses / period; + + for (int i = period + 1; i < candles.Count; i++) + { + decimal diff = candles[i].Close - candles[i - 1].Close; + if (diff >= 0) + { + avgGain = ((avgGain * (period - 1)) + diff) / period; + avgLoss = (avgLoss * (period - 1)) / period; + } + else + { + avgGain = (avgGain * (period - 1)) / period; + avgLoss = ((avgLoss * (period - 1)) + Math.Abs(diff)) / period; + } + } + + if (avgLoss == 0m) return 100m; + decimal rs = avgGain / avgLoss; + return 100m - (100m / (1m + rs)); + } + + public static decimal CalculateAtr(IReadOnlyList candles, int period = 14) + { + if (candles == null || candles.Count < 2 || period <= 0) return 0m; + int count = candles.Count; + int effectivePeriod = Math.Min(period, count - 1); + + decimal trSum = 0m; + for (int i = count - effectivePeriod; i < count; i++) + { + decimal high = candles[i].High; + decimal low = candles[i].Low; + decimal prevClose = candles[i - 1].Close; + + decimal tr = Math.Max(high - low, Math.Max(Math.Abs(high - prevClose), Math.Abs(low - prevClose))); + trSum += tr; + } + + return trSum / effectivePeriod; + } + + public static MacdResult CalculateMacd(IReadOnlyList candles, int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9) + { + if (candles == null || candles.Count < slowPeriod) + return new MacdResult(0m, 0m, 0m); + + decimal fastEma = CalculateEma(candles, fastPeriod); + decimal slowEma = CalculateEma(candles, slowPeriod); + decimal macdLine = fastEma - slowEma; + + // Calculate series of MACD lines for signal line calculation + var macdHistory = new List(); + int start = Math.Max(0, candles.Count - (signalPeriod + 5)); + for (int i = start; i < candles.Count; i++) + { + var subCandles = candles.Take(i + 1).ToList(); + if (subCandles.Count >= slowPeriod) + { + var f = CalculateEma(subCandles, fastPeriod); + var s = CalculateEma(subCandles, slowPeriod); + var val = f - s; + macdHistory.Add(new CandleDto(candles[i].Timestamp, val, val, val, val, 0)); + } + } + + decimal signalLine = macdHistory.Count >= signalPeriod + ? CalculateEma(macdHistory, signalPeriod) + : macdLine; + + decimal histogram = macdLine - signalLine; + return new MacdResult(macdLine, signalLine, histogram); + } + + public static BollingerBandsResult CalculateBollingerBands(IReadOnlyList candles, int period = 20, decimal multiplier = 2.0m) + { + if (candles == null || candles.Count < period || period <= 0) + return new BollingerBandsResult(0m, 0m, 0m, 0m, 0m); + + decimal sma = CalculateSma(candles, period); + + decimal sumSquares = 0m; + for (int i = candles.Count - period; i < candles.Count; i++) + { + decimal diff = candles[i].Close - sma; + sumSquares += diff * diff; + } + decimal stdDev = (decimal)Math.Sqrt((double)(sumSquares / period)); + + decimal upper = sma + (multiplier * stdDev); + decimal lower = sma - (multiplier * stdDev); + decimal bandwidth = sma > 0 ? ((upper - lower) / sma) * 100m : 0m; + decimal currentClose = candles.Last().Close; + decimal percentB = (upper - lower) > 0 ? (currentClose - lower) / (upper - lower) : 0.5m; + + return new BollingerBandsResult(upper, sma, lower, bandwidth, percentB); + } + + public static KeltnerChannelResult CalculateKeltnerChannels(IReadOnlyList candles, int period = 20, decimal atrMultiplier = 1.5m) + { + if (candles == null || candles.Count < period) + return new KeltnerChannelResult(0m, 0m, 0m); + + decimal ema = CalculateEma(candles, period); + decimal atr = CalculateAtr(candles, period); + + decimal upper = ema + (atrMultiplier * atr); + decimal lower = ema - (atrMultiplier * atr); + + return new KeltnerChannelResult(upper, ema, lower); + } + + public static SqueezeResult CalculateVolatilitySqueeze(IReadOnlyList candles) + { + var bb = CalculateBollingerBands(candles, 20, 2.0m); + var kc = CalculateKeltnerChannels(candles, 20, 1.5m); + + bool inSqueeze = bb.LowerBand > kc.LowerBand && bb.UpperBand < kc.UpperBand; + + var macd = CalculateMacd(candles, 12, 26, 9); + decimal momentum = macd.Histogram; + + string state = "NONE"; + if (inSqueeze) + { + state = "ON"; + } + else if (momentum > 0) + { + state = "FIRED_BULLISH"; + } + else if (momentum < 0) + { + state = "FIRED_BEARISH"; + } + + return new SqueezeResult(inSqueeze, momentum, state); + } + + public static SuperTrendResult CalculateSuperTrend(IReadOnlyList candles, int period = 10, decimal multiplier = 3.0m) + { + if (candles == null || candles.Count < period) + return new SuperTrendResult(0m, SignalDirection.Neutral, false); + + decimal atr = CalculateAtr(candles, period); + var last = candles.Last(); + decimal hl2 = (last.High + last.Low) / 2m; + + decimal basicUpperBand = hl2 + (multiplier * atr); + decimal basicLowerBand = hl2 - (multiplier * atr); + + // Determine trend relative to previous candle + decimal prevClose = candles.Count > 1 ? candles[^2].Close : last.Close; + SignalDirection dir = last.Close > basicUpperBand ? SignalDirection.Buy : + last.Close < basicLowerBand ? SignalDirection.Sell : + (last.Close >= prevClose ? SignalDirection.Buy : SignalDirection.Sell); + + decimal superTrendValue = dir == SignalDirection.Buy ? basicLowerBand : basicUpperBand; + bool isFlipped = (prevClose < basicUpperBand && last.Close > basicUpperBand) || + (prevClose > basicLowerBand && last.Close < basicLowerBand); + + return new SuperTrendResult(superTrendValue, dir, isFlipped); + } + + public static AdxResult CalculateAdx(IReadOnlyList candles, int period = 14) + { + if (candles == null || candles.Count <= period * 2) + return new AdxResult(15m, 15m, 15m, false); + + decimal trSum = 0m; + decimal plusDmSum = 0m; + decimal minusDmSum = 0m; + + for (int i = candles.Count - period; i < candles.Count; i++) + { + var curr = candles[i]; + var prev = candles[i - 1]; + + decimal upMove = curr.High - prev.High; + decimal downMove = prev.Low - curr.Low; + + decimal plusDm = (upMove > downMove && upMove > 0) ? upMove : 0m; + decimal minusDm = (downMove > upMove && downMove > 0) ? downMove : 0m; + + decimal tr = Math.Max(curr.High - curr.Low, Math.Max(Math.Abs(curr.High - prev.Close), Math.Abs(curr.Low - prev.Close))); + + trSum += tr; + plusDmSum += plusDm; + minusDmSum += minusDm; + } + + if (trSum == 0m) return new AdxResult(0m, 0m, 0m, false); + + decimal plusDi = (plusDmSum / trSum) * 100m; + decimal minusDi = (minusDmSum / trSum) * 100m; + decimal diSum = plusDi + minusDi; + decimal dx = diSum > 0 ? (Math.Abs(plusDi - minusDi) / diSum) * 100m : 0m; + + bool isTrending = dx >= 25m; + return new AdxResult(dx, plusDi, minusDi, isTrending); + } + + public static decimal CalculateVwap(IReadOnlyList candles) + { + if (candles == null || candles.Count == 0) return 0m; + + decimal totalTypicalPriceVolume = 0m; + long totalVolume = 0; + + foreach (var c in candles) + { + decimal typicalPrice = (c.High + c.Low + c.Close) / 3m; + totalTypicalPriceVolume += typicalPrice * c.Volume; + totalVolume += c.Volume; + } + + return totalVolume > 0 ? totalTypicalPriceVolume / totalVolume : candles.Last().Close; + } +} diff --git a/FinlyticTechnicals/Migrations/20260819182054_Init.Designer.cs b/FinlyticTechnicals/Migrations/20260819182054_Init.Designer.cs new file mode 100644 index 0000000..72268fc --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260819182054_Init.Designer.cs @@ -0,0 +1,289 @@ +// +using System; +using FinlyticTechnicals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + [DbContext(typeof(TechnicalAnalysisDbContext))] + [Migration("20260819182054_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18,4)"); + + b.Property("Bid") + .HasColumnType("decimal(18,4)"); + + b.Property("Close") + .HasColumnType("decimal(18,4)"); + + b.Property("High") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Low") + .HasColumnType("decimal(18,4)"); + + b.Property("Open") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("Isin", "Timeframe", "TimestampUtc"); + + b.ToTable("fta_candles"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bias") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtraData") + .HasColumnType("jsonb"); + + b.Property("InvalidationLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyPriceLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("LowerBoundary") + .HasColumnType("decimal(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PatternType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpperBoundary") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("PatternType"); + + b.HasIndex("QualityScore"); + + b.HasIndex("Isin", "DetectedAtUtc"); + + b.ToTable("fta_detected_patterns"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b => + { + b.Property("SetupId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentAtr") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EstimatedRiskRewardRatio") + .HasColumnType("decimal(8,2)"); + + b.Property("ExitPlan") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorSnapshot") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InvalidationPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTopPick") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Rating") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("StrategyKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StrategyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TriggeringPatterns") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("SetupId"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("IsTopPick"); + + b.HasIndex("QualityScore"); + + b.HasIndex("Isin", "IsActive", "ExpiresAtUtc"); + + b.ToTable("fta_technical_setups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260819182054_Init.cs b/FinlyticTechnicals/Migrations/20260819182054_Init.cs new file mode 100644 index 0000000..ae73d8f --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260819182054_Init.cs @@ -0,0 +1,178 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DynamicSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + ValueJson = table.Column(type: "text", nullable: false), + ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DynamicSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "fta_candles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Timeframe = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + TimestampUtc = table.Column(type: "timestamp with time zone", nullable: false), + Open = table.Column(type: "numeric(18,4)", nullable: false), + High = table.Column(type: "numeric(18,4)", nullable: false), + Low = table.Column(type: "numeric(18,4)", nullable: false), + Close = table.Column(type: "numeric(18,4)", nullable: false), + Volume = table.Column(type: "bigint", nullable: false), + Bid = table.Column(type: "numeric(18,4)", nullable: true), + Ask = table.Column(type: "numeric(18,4)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_fta_candles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "fta_detected_patterns", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Timeframe = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + PatternType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Category = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Bias = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + KeyPriceLevel = table.Column(type: "numeric(18,4)", nullable: false), + UpperBoundary = table.Column(type: "numeric(18,4)", nullable: false), + LowerBoundary = table.Column(type: "numeric(18,4)", nullable: false), + InvalidationLevel = table.Column(type: "numeric(18,4)", nullable: false), + QualityScore = table.Column(type: "numeric(6,2)", nullable: false), + Description = table.Column(type: "text", nullable: false), + ExtraData = table.Column(type: "jsonb", nullable: true), + DetectedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_fta_detected_patterns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "fta_technical_setups", + columns: table => new + { + SetupId = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Timeframe = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + StrategyKey = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + StrategyName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Direction = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + QualityScore = table.Column(type: "numeric(6,2)", nullable: false), + CurrentPrice = table.Column(type: "numeric(18,4)", nullable: false), + EntryPrice = table.Column(type: "numeric(18,4)", nullable: false), + InvalidationPrice = table.Column(type: "numeric(18,4)", nullable: false), + CurrentAtr = table.Column(type: "numeric(18,4)", nullable: false), + EstimatedRiskRewardRatio = table.Column(type: "numeric(8,2)", nullable: false), + ExitPlan = table.Column(type: "jsonb", nullable: false), + TechnicalRationale = table.Column(type: "text", nullable: false), + TriggeringPatterns = table.Column(type: "jsonb", nullable: false), + IndicatorSnapshot = table.Column(type: "jsonb", nullable: false), + IsTopPick = table.Column(type: "boolean", nullable: false), + Rating = table.Column(type: "character varying(5)", maxLength: 5, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_fta_technical_setups", x => x.SetupId); + }); + + migrationBuilder.CreateIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_fta_candles_Isin_Timeframe_TimestampUtc", + table: "fta_candles", + columns: new[] { "Isin", "Timeframe", "TimestampUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_fta_candles_TimestampUtc", + table: "fta_candles", + column: "TimestampUtc"); + + migrationBuilder.CreateIndex( + name: "IX_fta_detected_patterns_Isin_DetectedAtUtc", + table: "fta_detected_patterns", + columns: new[] { "Isin", "DetectedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_fta_detected_patterns_PatternType", + table: "fta_detected_patterns", + column: "PatternType"); + + migrationBuilder.CreateIndex( + name: "IX_fta_detected_patterns_QualityScore", + table: "fta_detected_patterns", + column: "QualityScore"); + + migrationBuilder.CreateIndex( + name: "IX_fta_technical_setups_CreatedAtUtc", + table: "fta_technical_setups", + column: "CreatedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_fta_technical_setups_Isin_IsActive_ExpiresAtUtc", + table: "fta_technical_setups", + columns: new[] { "Isin", "IsActive", "ExpiresAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_fta_technical_setups_IsTopPick", + table: "fta_technical_setups", + column: "IsTopPick"); + + migrationBuilder.CreateIndex( + name: "IX_fta_technical_setups_QualityScore", + table: "fta_technical_setups", + column: "QualityScore"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DynamicSettings"); + + migrationBuilder.DropTable( + name: "fta_candles"); + + migrationBuilder.DropTable( + name: "fta_detected_patterns"); + + migrationBuilder.DropTable( + name: "fta_technical_setups"); + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.Designer.cs b/FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.Designer.cs new file mode 100644 index 0000000..92a14e3 --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.Designer.cs @@ -0,0 +1,291 @@ +// +using System; +using FinlyticTechnicals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + [DbContext(typeof(TechnicalAnalysisDbContext))] + [Migration("20260821153627_SyncTechnicalsModelDrift")] + partial class SyncTechnicalsModelDrift + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18,4)"); + + b.Property("Bid") + .HasColumnType("decimal(18,4)"); + + b.Property("Close") + .HasColumnType("decimal(18,4)"); + + b.Property("High") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Low") + .HasColumnType("decimal(18,4)"); + + b.Property("Open") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("Isin", "Timeframe", "TimestampUtc"); + + b.ToTable("fta_candles"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bias") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtraData") + .HasColumnType("jsonb"); + + b.Property("InvalidationLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyPriceLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("LowerBoundary") + .HasColumnType("decimal(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PatternType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpperBoundary") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("PatternType"); + + b.HasIndex("QualityScore"); + + b.HasIndex("Isin", "DetectedAtUtc"); + + b.ToTable("fta_detected_patterns"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b => + { + b.Property("SetupId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentAtr") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EstimatedRiskRewardRatio") + .HasColumnType("decimal(8,2)"); + + b.Property("ExitPlan") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorSnapshot") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InvalidationPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTopPick") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Rating") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("StrategyKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StrategyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TriggeringPatterns") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("SetupId"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("IsTopPick"); + + b.HasIndex("QualityScore"); + + b.HasIndex("IsActive", "IsTopPick", "QualityScore"); + + b.HasIndex("Isin", "IsActive", "ExpiresAtUtc"); + + b.ToTable("fta_technical_setups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.cs b/FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.cs new file mode 100644 index 0000000..0651fe5 --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260821153627_SyncTechnicalsModelDrift.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + /// + public partial class SyncTechnicalsModelDrift : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_fta_technical_setups_IsActive_IsTopPick_QualityScore", + table: "fta_technical_setups", + columns: new[] { "IsActive", "IsTopPick", "QualityScore" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_fta_technical_setups_IsActive_IsTopPick_QualityScore", + table: "fta_technical_setups"); + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.Designer.cs b/FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.Designer.cs new file mode 100644 index 0000000..6ed7270 --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.Designer.cs @@ -0,0 +1,331 @@ +// +using System; +using FinlyticTechnicals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + [DbContext(typeof(TechnicalAnalysisDbContext))] + [Migration("20260822081353_AddMonitoredUniverseTable")] + partial class AddMonitoredUniverseTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18,4)"); + + b.Property("Bid") + .HasColumnType("decimal(18,4)"); + + b.Property("Close") + .HasColumnType("decimal(18,4)"); + + b.Property("High") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Low") + .HasColumnType("decimal(18,4)"); + + b.Property("Open") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("Isin", "Timeframe", "TimestampUtc"); + + b.ToTable("fta_candles"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bias") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtraData") + .HasColumnType("jsonb"); + + b.Property("InvalidationLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyPriceLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("LowerBoundary") + .HasColumnType("decimal(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PatternType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpperBoundary") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("PatternType"); + + b.HasIndex("QualityScore"); + + b.HasIndex("Isin", "DetectedAtUtc"); + + b.ToTable("fta_detected_patterns"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaMonitoredUniverseAssetEntity", b => + { + b.Property("Isin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AddedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Symbol") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.HasKey("Isin"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Source"); + + b.ToTable("fta_monitored_universe_assets"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b => + { + b.Property("SetupId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentAtr") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EstimatedRiskRewardRatio") + .HasColumnType("decimal(8,2)"); + + b.Property("ExitPlan") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorSnapshot") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InvalidationPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTopPick") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Rating") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("StrategyKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StrategyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TriggeringPatterns") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UniverseEnteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UniverseSource") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SetupId"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("IsTopPick"); + + b.HasIndex("QualityScore"); + + b.HasIndex("IsActive", "IsTopPick", "QualityScore"); + + b.HasIndex("Isin", "IsActive", "ExpiresAtUtc"); + + b.ToTable("fta_technical_setups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.cs b/FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.cs new file mode 100644 index 0000000..1f3e805 --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260822081353_AddMonitoredUniverseTable.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + /// + public partial class AddMonitoredUniverseTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UniverseEnteredAtUtc", + table: "fta_technical_setups", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "UniverseSource", + table: "fta_technical_setups", + type: "character varying(20)", + maxLength: 20, + nullable: true); + + migrationBuilder.CreateTable( + name: "fta_monitored_universe_assets", + columns: table => new + { + Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + Source = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Priority = table.Column(type: "integer", nullable: false), + AddedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_fta_monitored_universe_assets", x => x.Isin); + }); + + migrationBuilder.CreateIndex( + name: "IX_fta_monitored_universe_assets_ExpiresAtUtc", + table: "fta_monitored_universe_assets", + column: "ExpiresAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_fta_monitored_universe_assets_Source", + table: "fta_monitored_universe_assets", + column: "Source"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "fta_monitored_universe_assets"); + + migrationBuilder.DropColumn( + name: "UniverseEnteredAtUtc", + table: "fta_technical_setups"); + + migrationBuilder.DropColumn( + name: "UniverseSource", + table: "fta_technical_setups"); + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.Designer.cs b/FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.Designer.cs new file mode 100644 index 0000000..96bea1b --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.Designer.cs @@ -0,0 +1,335 @@ +// +using System; +using FinlyticTechnicals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + [DbContext(typeof(TechnicalAnalysisDbContext))] + [Migration("20260822090549_AddRegimeToTechnicalSetups")] + partial class AddRegimeToTechnicalSetups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18,4)"); + + b.Property("Bid") + .HasColumnType("decimal(18,4)"); + + b.Property("Close") + .HasColumnType("decimal(18,4)"); + + b.Property("High") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Low") + .HasColumnType("decimal(18,4)"); + + b.Property("Open") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("Isin", "Timeframe", "TimestampUtc"); + + b.ToTable("fta_candles"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bias") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtraData") + .HasColumnType("jsonb"); + + b.Property("InvalidationLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyPriceLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("LowerBoundary") + .HasColumnType("decimal(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PatternType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpperBoundary") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("PatternType"); + + b.HasIndex("QualityScore"); + + b.HasIndex("Isin", "DetectedAtUtc"); + + b.ToTable("fta_detected_patterns"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaMonitoredUniverseAssetEntity", b => + { + b.Property("Isin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AddedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Symbol") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.HasKey("Isin"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Source"); + + b.ToTable("fta_monitored_universe_assets"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b => + { + b.Property("SetupId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentAtr") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EstimatedRiskRewardRatio") + .HasColumnType("decimal(8,2)"); + + b.Property("ExitPlan") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorSnapshot") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InvalidationPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTopPick") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Rating") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Regime") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("StrategyKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StrategyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TriggeringPatterns") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UniverseEnteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UniverseSource") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SetupId"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("IsTopPick"); + + b.HasIndex("QualityScore"); + + b.HasIndex("IsActive", "IsTopPick", "QualityScore"); + + b.HasIndex("Isin", "IsActive", "ExpiresAtUtc"); + + b.ToTable("fta_technical_setups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.cs b/FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.cs new file mode 100644 index 0000000..4d771fb --- /dev/null +++ b/FinlyticTechnicals/Migrations/20260822090549_AddRegimeToTechnicalSetups.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + /// + public partial class AddRegimeToTechnicalSetups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Regime", + table: "fta_technical_setups", + type: "character varying(30)", + maxLength: 30, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Regime", + table: "fta_technical_setups"); + } + } +} diff --git a/FinlyticTechnicals/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs b/FinlyticTechnicals/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs new file mode 100644 index 0000000..d85adf0 --- /dev/null +++ b/FinlyticTechnicals/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs @@ -0,0 +1,332 @@ +// +using System; +using FinlyticTechnicals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicals.Migrations +{ + [DbContext(typeof(TechnicalAnalysisDbContext))] + partial class TechnicalAnalysisDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18,4)"); + + b.Property("Bid") + .HasColumnType("decimal(18,4)"); + + b.Property("Close") + .HasColumnType("decimal(18,4)"); + + b.Property("High") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Low") + .HasColumnType("decimal(18,4)"); + + b.Property("Open") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimestampUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("Isin", "Timeframe", "TimestampUtc"); + + b.ToTable("fta_candles"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaDetectedPatternEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bias") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtraData") + .HasColumnType("jsonb"); + + b.Property("InvalidationLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyPriceLevel") + .HasColumnType("decimal(18,4)"); + + b.Property("LowerBoundary") + .HasColumnType("decimal(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PatternType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpperBoundary") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("PatternType"); + + b.HasIndex("QualityScore"); + + b.HasIndex("Isin", "DetectedAtUtc"); + + b.ToTable("fta_detected_patterns"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaMonitoredUniverseAssetEntity", b => + { + b.Property("Isin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AddedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Symbol") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.HasKey("Isin"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Source"); + + b.ToTable("fta_monitored_universe_assets"); + }); + + modelBuilder.Entity("FinlyticTechnicals.Entities.FtaTechnicalSetupEntity", b => + { + b.Property("SetupId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentAtr") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EstimatedRiskRewardRatio") + .HasColumnType("decimal(8,2)"); + + b.Property("ExitPlan") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorSnapshot") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InvalidationPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTopPick") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("QualityScore") + .HasColumnType("decimal(6,2)"); + + b.Property("Rating") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Regime") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("StrategyKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StrategyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TriggeringPatterns") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UniverseEnteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UniverseSource") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SetupId"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("IsTopPick"); + + b.HasIndex("QualityScore"); + + b.HasIndex("IsActive", "IsTopPick", "QualityScore"); + + b.HasIndex("Isin", "IsActive", "ExpiresAtUtc"); + + b.ToTable("fta_technical_setups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicals/Patterns/Candlesticks/CandlestickPatternDetectors.cs b/FinlyticTechnicals/Patterns/Candlesticks/CandlestickPatternDetectors.cs new file mode 100644 index 0000000..8b45b64 --- /dev/null +++ b/FinlyticTechnicals/Patterns/Candlesticks/CandlestickPatternDetectors.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Patterns.Candlesticks; + +/// +/// Detects Bullish Hammer (long lower wick at support) and Bearish Shooting Star (long upper wick at resistance). +/// +public class HammerShootingStarDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.Hammer; + public PatternCategory Category => PatternCategory.Candlestick; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 5) return null; + + var current = candles.Last(); + var prev = candles[^2]; + + decimal body = Math.Abs(current.Close - current.Open); + decimal upperShadow = current.High - Math.Max(current.Open, current.Close); + decimal lowerShadow = Math.Min(current.Open, current.Close) - current.Low; + decimal totalRange = current.High - current.Low; + + if (totalRange <= 0) return null; + + // Hammer: Lower shadow >= 2x body, upper shadow <= 0.2x body, downtrend context + if (lowerShadow >= 2.0m * Math.Max(body, 0.01m) && upperShadow <= 0.3m * totalRange && current.Close < prev.Close * 1.02m) + { + decimal score = Math.Min(95m, 60m + (lowerShadow / totalRange * 40m)); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.Hammer, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Bullish, + Name: "Bullish Hammer", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: current.Low, + UpperBoundary: current.High, + LowerBoundary: current.Low, + InvalidationLevel: current.Low * 0.995m, + QualityScore: score, + Description: $"Bullish hammer with {lowerShadow / totalRange:P0} rejection lower wick at {current.Low:F2}." + ); + } + + // Shooting Star: Upper shadow >= 2x body, lower shadow <= 0.2x body, uptrend context + if (upperShadow >= 2.0m * Math.Max(body, 0.01m) && lowerShadow <= 0.3m * totalRange && current.Close > prev.Close * 0.98m) + { + decimal score = Math.Min(95m, 60m + (upperShadow / totalRange * 40m)); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.ShootingStar, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Bearish, + Name: "Bearish Shooting Star", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: current.High, + UpperBoundary: current.High, + LowerBoundary: current.Low, + InvalidationLevel: current.High * 1.005m, + QualityScore: score, + Description: $"Bearish shooting star with {upperShadow / totalRange:P0} rejection upper wick at {current.High:F2}." + ); + } + + return null; + } +} + +/// +/// Detects Bullish and Bearish Engulfing candles. +/// +public class EngulfingPatternDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.BullishEngulfing; + public PatternCategory Category => PatternCategory.Candlestick; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 3) return null; + + var curr = candles.Last(); + var prev = candles[^2]; + + bool prevBearish = prev.Close < prev.Open; + bool currBullish = curr.Close > curr.Open; + + // Bullish Engulfing: previous red, current green completely engulfing previous body + if (prevBearish && currBullish && curr.Open <= prev.Close && curr.Close >= prev.Open) + { + decimal score = Math.Min(90m, 70m + (curr.Volume > prev.Volume ? 15m : 0m)); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.BullishEngulfing, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Bullish, + Name: "Bullish Engulfing", + Timeframe: context.Timeframe, + DetectedAt: curr.Timestamp, + KeyPriceLevel: curr.Open, + UpperBoundary: curr.High, + LowerBoundary: curr.Low, + InvalidationLevel: curr.Low * 0.995m, + QualityScore: score, + Description: $"Bullish engulfing candle covering previous range [{prev.Close:F2} - {prev.Open:F2}]." + ); + } + + bool prevBullish = prev.Close > prev.Open; + bool currBearish = curr.Close < curr.Open; + + // Bearish Engulfing: previous green, current red completely engulfing previous body + if (prevBullish && currBearish && curr.Open >= prev.Close && curr.Close <= prev.Open) + { + decimal score = Math.Min(90m, 70m + (curr.Volume > prev.Volume ? 15m : 0m)); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.BearishEngulfing, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Bearish, + Name: "Bearish Engulfing", + Timeframe: context.Timeframe, + DetectedAt: curr.Timestamp, + KeyPriceLevel: curr.Open, + UpperBoundary: curr.High, + LowerBoundary: curr.Low, + InvalidationLevel: curr.High * 1.005m, + QualityScore: score, + Description: $"Bearish engulfing candle covering previous range [{prev.Open:F2} - {prev.Close:F2}]." + ); + } + + return null; + } +} + +/// +/// Detects Morning Star and Evening Star 3-bar reversal patterns. +/// +public class MorningEveningStarDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.MorningStar; + public PatternCategory Category => PatternCategory.Candlestick; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 4) return null; + + var c1 = candles[^3]; + var c2 = candles[^2]; // Star + var c3 = candles.Last(); + + decimal body1 = Math.Abs(c1.Close - c1.Open); + decimal body2 = Math.Abs(c2.Close - c2.Open); + decimal body3 = Math.Abs(c3.Close - c3.Open); + + // Morning Star: Large Bearish + Small Star + Strong Bullish closing > 50% into candle 1 + if (c1.Close < c1.Open && body2 < body1 * 0.4m && c3.Close > c3.Open && c3.Close >= (c1.Open + c1.Close) / 2m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.MorningStar, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Bullish, + Name: "Morning Star", + Timeframe: context.Timeframe, + DetectedAt: c3.Timestamp, + KeyPriceLevel: c2.Low, + UpperBoundary: c3.High, + LowerBoundary: c2.Low, + InvalidationLevel: c2.Low * 0.995m, + QualityScore: 85m, + Description: $"Morning star reversal with low at {c2.Low:F2}." + ); + } + + // Evening Star: Large Bullish + Small Star + Strong Bearish closing < 50% into candle 1 + if (c1.Close > c1.Open && body2 < body1 * 0.4m && c3.Close < c3.Open && c3.Close <= (c1.Open + c1.Close) / 2m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.EveningStar, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Bearish, + Name: "Evening Star", + Timeframe: context.Timeframe, + DetectedAt: c3.Timestamp, + KeyPriceLevel: c2.High, + UpperBoundary: c2.High, + LowerBoundary: c3.Low, + InvalidationLevel: c2.High * 1.005m, + QualityScore: 85m, + Description: $"Evening star reversal with peak at {c2.High:F2}." + ); + } + + return null; + } +} + +/// +/// Detects Doji indecision candles at key swing points. +/// +public class DojiPatternDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.Doji; + public PatternCategory Category => PatternCategory.Candlestick; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 3) return null; + + var curr = candles.Last(); + decimal body = Math.Abs(curr.Close - curr.Open); + decimal totalRange = curr.High - curr.Low; + + if (totalRange <= 0) return null; + + if (body <= totalRange * 0.10m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.Doji, + Category: PatternCategory.Candlestick, + Bias: PatternBias.Neutral, + Name: "Doji", + Timeframe: context.Timeframe, + DetectedAt: curr.Timestamp, + KeyPriceLevel: curr.Close, + UpperBoundary: curr.High, + LowerBoundary: curr.Low, + InvalidationLevel: curr.Low, + QualityScore: 65m, + Description: $"Doji indecision bar with tight body ({body:F2}) and range [{curr.Low:F2} - {curr.High:F2}]." + ); + } + + return null; + } +} diff --git a/FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectors.cs b/FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectors.cs new file mode 100644 index 0000000..b81b83d --- /dev/null +++ b/FinlyticTechnicals/Patterns/ChartPatterns/ChartPatternDetectors.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Patterns.ChartPatterns; + +/// +/// Detects Double Bottom (W-reversal) and Double Top (M-reversal) formations. +/// +public class DoubleTopBottomDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.DoubleBottom; + public PatternCategory Category => PatternCategory.Chart; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 25) return null; + + // Search for two prominent swing lows within the last 20 candles + int n = candles.Count; + var recent = candles.TakeLast(25).ToList(); + + decimal min1 = decimal.MaxValue; + int min1Idx = -1; + decimal min2 = decimal.MaxValue; + int min2Idx = -1; + decimal peakBetween = 0m; + + for (int i = 2; i < recent.Count - 2; i++) + { + if (recent[i].Low <= recent[i - 1].Low && recent[i].Low <= recent[i - 2].Low && + recent[i].Low <= recent[i + 1].Low && recent[i].Low <= recent[i + 2].Low) + { + if (min1Idx == -1) + { + min1 = recent[i].Low; + min1Idx = i; + } + else if (min2Idx == -1 && i > min1Idx + 4) + { + min2 = recent[i].Low; + min2Idx = i; + break; + } + } + } + + if (min1Idx != -1 && min2Idx != -1) + { + // Calculate peak between the two lows (neckline) + for (int i = min1Idx; i <= min2Idx; i++) + { + if (recent[i].High > peakBetween) peakBetween = recent[i].High; + } + + decimal priceDifference = Math.Abs(min1 - min2) / min1; + var current = recent.Last(); + + // Double Bottom validation: lows within 1.5% of each other, current price breaking above neckline or holding second bottom + if (priceDifference <= 0.015m && current.Close >= min2 && peakBetween > min1 * 1.01m) + { + decimal target = peakBetween + (peakBetween - Math.Min(min1, min2)); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.DoubleBottom, + Category: PatternCategory.Chart, + Bias: PatternBias.Bullish, + Name: "Double Bottom (W-Pattern)", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: peakBetween, + UpperBoundary: target, + LowerBoundary: Math.Min(min1, min2), + InvalidationLevel: Math.Min(min1, min2) * 0.995m, + QualityScore: 82m, + Description: $"Double bottom with bottoms at {min1:F2} & {min2:F2}, neckline at {peakBetween:F2}." + ); + } + } + + return null; + } +} + +/// +/// Detects Head & Shoulders and Inverse Head & Shoulders reversal formations. +/// +public class HeadAndShouldersDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.HeadAndShoulders; + public PatternCategory Category => PatternCategory.Chart; + + 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, Head, Right Shoulder + // Head must be significantly higher than Left and Right shoulders + decimal maxPrice = recent.Max(c => c.High); + int headIdx = recent.FindIndex(c => c.High == maxPrice); + + if (headIdx >= 5 && headIdx <= recent.Count - 5) + { + decimal leftShoulder = recent.Take(headIdx).Max(c => c.High); + decimal rightShoulder = recent.Skip(headIdx + 1).Max(c => c.High); + + if (maxPrice > leftShoulder * 1.01m && maxPrice > rightShoulder * 1.01m && + Math.Abs(leftShoulder - rightShoulder) / leftShoulder <= 0.03m) + { + decimal neckline = recent.Skip(headIdx - 3).Take(6).Min(c => c.Low); + var current = recent.Last(); + + if (current.Close <= rightShoulder) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.HeadAndShoulders, + Category: PatternCategory.Chart, + Bias: PatternBias.Bearish, + Name: "Head & Shoulders", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: neckline, + UpperBoundary: maxPrice, + LowerBoundary: neckline - (maxPrice - neckline), + InvalidationLevel: maxPrice * 1.005m, + QualityScore: 85m, + Description: $"Bearish Head & Shoulders with Head at {maxPrice:F2}, Shoulders ~{leftShoulder:F2}, Neckline {neckline:F2}." + ); + } + } + } + + return null; + } +} + +/// +/// Detects Ascending and Descending Triangle consolidations. +/// +public class TrianglePatternDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.AscendingTriangle; + public PatternCategory Category => PatternCategory.Chart; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 20) return null; + + var recent = candles.TakeLast(20).ToList(); + decimal highResistance = recent.Take(15).Max(c => c.High); + + // Check if highs are flat (horizontal resistance) while lows are rising (higher lows) + decimal low1 = recent.Take(7).Min(c => c.Low); + decimal low2 = recent.Skip(7).Take(7).Min(c => c.Low); + decimal low3 = recent.Skip(14).Min(c => c.Low); + + if (low3 > low2 && low2 > low1 && Math.Abs(recent.Last().High - highResistance) / highResistance <= 0.01m) + { + var curr = recent.Last(); + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.AscendingTriangle, + Category: PatternCategory.Chart, + Bias: PatternBias.Bullish, + Name: "Ascending Triangle", + Timeframe: context.Timeframe, + DetectedAt: curr.Timestamp, + KeyPriceLevel: highResistance, + UpperBoundary: highResistance + (highResistance - low1), + LowerBoundary: low3, + InvalidationLevel: low3 * 0.995m, + QualityScore: 80m, + Description: $"Ascending triangle with horizontal resistance at {highResistance:F2} and rising lows ({low1:F2} -> {low2:F2} -> {low3:F2})." + ); + } + + return null; + } +} diff --git a/FinlyticTechnicals/Patterns/IPatternDetector.cs b/FinlyticTechnicals/Patterns/IPatternDetector.cs new file mode 100644 index 0000000..aeb1762 --- /dev/null +++ b/FinlyticTechnicals/Patterns/IPatternDetector.cs @@ -0,0 +1,17 @@ +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Patterns; + +/// +/// Isolated detector contract for a specific candlestick, chart, or SMC pattern. +/// +public interface IPatternDetector +{ + PatternType HandledType { get; } + PatternCategory Category { get; } + + /// + /// Evaluates the technical context and returns a detected pattern or null if conditions are not met. + /// + PatternResultDto? Evaluate(TechnicalContext context); +} diff --git a/FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectors.cs b/FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectors.cs new file mode 100644 index 0000000..57d849a --- /dev/null +++ b/FinlyticTechnicals/Patterns/SmartMoney/SmcPatternDetectors.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Patterns.SmartMoney; + +/// +/// Detects Bullish and Bearish Fair Value Gaps (FVG) across 3-candle sequences. +/// +public class FairValueGapDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.FairValueGapBullish; + public PatternCategory Category => PatternCategory.SmartMoney; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 3) return null; + + var c1 = candles[^3]; + var c2 = candles[^2]; // Impulse candle + var c3 = candles.Last(); + + // Bullish FVG: Candle 1 High < Candle 3 Low (Gap between c1.High and c3.Low) + if (c3.Low > c1.High && c2.Close > c2.Open) + { + decimal gapSize = c3.Low - c1.High; + decimal midGap = (c3.Low + c1.High) / 2m; + + if (gapSize >= context.CurrentAtr * 0.25m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.FairValueGapBullish, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bullish, + Name: "Bullish Fair Value Gap (FVG)", + Timeframe: context.Timeframe, + DetectedAt: c3.Timestamp, + KeyPriceLevel: midGap, + UpperBoundary: c3.Low, + LowerBoundary: c1.High, + InvalidationLevel: c1.High * 0.995m, + QualityScore: 88m, + Description: $"Bullish FVG imbalance zone [{c1.High:F2} - {c3.Low:F2}] with midpoint at {midGap:F2}." + ); + } + } + + // Bearish FVG: Candle 1 Low > Candle 3 High (Gap between c3.High and c1.Low) + if (c3.High < c1.Low && c2.Close < c2.Open) + { + decimal gapSize = c1.Low - c3.High; + decimal midGap = (c1.Low + c3.High) / 2m; + + if (gapSize >= context.CurrentAtr * 0.25m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.FairValueGapBearish, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bearish, + Name: "Bearish Fair Value Gap (FVG)", + Timeframe: context.Timeframe, + DetectedAt: c3.Timestamp, + KeyPriceLevel: midGap, + UpperBoundary: c1.Low, + LowerBoundary: c3.High, + InvalidationLevel: c1.Low * 1.005m, + QualityScore: 88m, + Description: $"Bearish FVG imbalance zone [{c3.High:F2} - {c1.Low:F2}] with midpoint at {midGap:F2}." + ); + } + } + + return null; + } +} + +/// +/// Detects Liquidity Sweeps where price takes out multi-period swing highs/lows and immediately rejects back inside the range. +/// +public class LiquiditySweepDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.LiquiditySweepLow; + public PatternCategory Category => PatternCategory.SmartMoney; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 20) return null; + + var lookback = candles.Take(candles.Count - 1).TakeLast(20).ToList(); + var current = candles.Last(); + + decimal swingLow = lookback.Min(c => c.Low); + decimal swingHigh = lookback.Max(c => c.High); + + // Bullish Liquidity Sweep (Sweep Low): Pierced previous swing low but closed back above it + if (current.Low < swingLow && current.Close > swingLow) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.LiquiditySweepLow, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bullish, + Name: "Bullish Liquidity Sweep (Stop Hunt)", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: swingLow, + UpperBoundary: swingHigh, + LowerBoundary: current.Low, + InvalidationLevel: current.Low * 0.995m, + QualityScore: 92m, + Description: $"Bullish liquidity sweep below swing low {swingLow:F2} with wick rejection to {current.Low:F2}." + ); + } + + // Bearish Liquidity Sweep (Sweep High): Pierced previous swing high but closed back below it + if (current.High > swingHigh && current.Close < swingHigh) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.LiquiditySweepHigh, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bearish, + Name: "Bearish Liquidity Sweep (Buy-Side Sweep)", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: swingHigh, + UpperBoundary: current.High, + LowerBoundary: swingLow, + InvalidationLevel: current.High * 1.005m, + QualityScore: 92m, + Description: $"Bearish liquidity sweep above swing high {swingHigh:F2} with wick rejection to {current.High:F2}." + ); + } + + return null; + } +} + +/// +/// Detects Change of Character (CHoCH) structural trend reversals and Break of Structure (BOS) continuations. +/// +public class ChochBosDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.ChangeOfCharacter; + public PatternCategory Category => PatternCategory.SmartMoney; + + public PatternResultDto? Evaluate(TechnicalContext context) + { + var candles = context.PrimaryCandles; + if (candles.Count < 20) return null; + + var current = candles.Last(); + var prevCandles = candles.Take(candles.Count - 1).TakeLast(15).ToList(); + + decimal priorSwingHigh = prevCandles.Max(c => c.High); + decimal priorSwingLow = prevCandles.Min(c => c.Low); + + // Bullish CHoCH: Clean candle body close above previous major swing high + if (current.Close > priorSwingHigh && current.Open < priorSwingHigh) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.ChangeOfCharacter, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bullish, + Name: "Bullish Change of Character (CHoCH)", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: priorSwingHigh, + UpperBoundary: current.Close + context.CurrentAtr * 2m, + LowerBoundary: priorSwingLow, + InvalidationLevel: priorSwingLow, + QualityScore: 90m, + Description: $"Bullish structural break closing above swing high {priorSwingHigh:F2}." + ); + } + + // Bearish CHoCH: Clean candle body close below previous major swing low + if (current.Close < priorSwingLow && current.Open > priorSwingLow) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.ChangeOfCharacter, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bearish, + Name: "Bearish Change of Character (CHoCH)", + Timeframe: context.Timeframe, + DetectedAt: current.Timestamp, + KeyPriceLevel: priorSwingLow, + UpperBoundary: priorSwingHigh, + LowerBoundary: current.Close - context.CurrentAtr * 2m, + InvalidationLevel: priorSwingHigh, + QualityScore: 90m, + Description: $"Bearish structural break closing below swing low {priorSwingLow:F2}." + ); + } + + return null; + } +} + +/// +/// Detects institutional Order Blocks (last opposing candle before a strong directional displacement). +/// +public class OrderBlockDetector : IPatternDetector +{ + public PatternType HandledType => PatternType.OrderBlock; + public PatternCategory Category => PatternCategory.SmartMoney; + + 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(); + + // Bullish Order Block: Red candle followed by 2 strong green candles that expand price > 1.5 ATR + if (obCandle.Close < obCandle.Open && impulse1.Close > impulse1.Open && impulse2.Close > impulse2.Open) + { + decimal displacement = impulse2.Close - obCandle.Low; + if (displacement >= context.CurrentAtr * 1.5m) + { + return new PatternResultDto( + Id: Guid.NewGuid(), + Type: PatternType.OrderBlock, + Category: PatternCategory.SmartMoney, + Bias: PatternBias.Bullish, + Name: "Bullish Institutional Order Block", + Timeframe: context.Timeframe, + DetectedAt: impulse2.Timestamp, + KeyPriceLevel: (obCandle.Open + obCandle.Close) / 2m, + UpperBoundary: obCandle.High, + LowerBoundary: obCandle.Low, + InvalidationLevel: obCandle.Low * 0.995m, + QualityScore: 86m, + Description: $"Bullish order block zone [{obCandle.Low:F2} - {obCandle.High:F2}] with strong displacement." + ); + } + } + + return null; + } +} diff --git a/FinlyticTechnicals/Program.cs b/FinlyticTechnicals/Program.cs new file mode 100644 index 0000000..77e4b8d --- /dev/null +++ b/FinlyticTechnicals/Program.cs @@ -0,0 +1,110 @@ +using System; +using System.Net.Http; +using FinlyticCore.Database; +using FinlyticCore.Services; +using FinlyticCore.Services.TradeRepublic; +using FinlyticCore.Services.Yahoo; +using FinlyticTechnicals.Database; +using FinlyticTechnicals.Patterns; +using FinlyticTechnicals.Patterns.Candlesticks; +using FinlyticTechnicals.Patterns.ChartPatterns; +using FinlyticTechnicals.Patterns.SmartMoney; +using FinlyticTechnicals.Services; +using FinlyticTechnicals.Strategies; +using FinlyticTechnicals.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +// 1. Register DbContext +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// 2. Register Core Services & Logger +builder.Services.AddSingleton(); +builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); + +// 3. Register HTTP & Market Data Clients +builder.Services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + UseCookies = true, + CookieContainer = new System.Net.CookieContainer() + }); +builder.Services.AddSingleton(); +builder.Services.AddTransient(); + +// 4. Register Trade Republic Ingestion & Real-Time Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 5. Register Pattern Detectors +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 6. Register Strategies +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 7. Register Technical Scoring Engine & Universe Manager +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 8. Register MQTT Client & RPC Bridge +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// 9. Register Technical Scanner Background Service +builder.Services.AddHostedService(); + + +var host = builder.Build(); + +// Run startup database migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; + await context.MigrateWithBootstrapAsync(connStr); + Console.WriteLine("Database migrations successfully executed for FinlyticTechnicals."); + + // The monitored-universe table backs TechnicalUniverseManager but is deliberately NOT meant to survive + // a restart: it is fully rebuilt within minutes from RefreshFavoritesAsync/RefreshDiscoveryAsync and + // fresh sentiment-spike events, and a stale row whose TTL never got swept because the process was down + // is worse than starting from an empty universe. + await context.MonitoredUniverseAssets.ExecuteDeleteAsync(); + Console.WriteLine("Monitored universe table cleared for fresh start."); + } + catch (Exception ex) + { + Console.WriteLine($"Migration error on startup: {ex.Message}"); + } +} + +await host.RunAsync(); diff --git a/FinlyticTechnicals/Services/ITAMqttRpcClient.cs b/FinlyticTechnicals/Services/ITAMqttRpcClient.cs new file mode 100644 index 0000000..be0a32f --- /dev/null +++ b/FinlyticTechnicals/Services/ITAMqttRpcClient.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; + +namespace FinlyticTechnicals.Services; + +public interface ITAMqttRpcClient +{ + Task SendRpcRequestAsync( + string channel, + TRequest requestData, + TimeSpan? timeout = null) + where TResponse : class + where TRequest : class; + + Task PublishAsync(string topic, T data, bool retain = false); +} diff --git a/FinlyticTechnicals/Services/MultiTimeframeCandleAggregator.cs b/FinlyticTechnicals/Services/MultiTimeframeCandleAggregator.cs new file mode 100644 index 0000000..872b883 --- /dev/null +++ b/FinlyticTechnicals/Services/MultiTimeframeCandleAggregator.cs @@ -0,0 +1,245 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; +using FinlyticTechnicals.Timeframe; +using FinlyticTechnicals.Util; + +namespace FinlyticTechnicals.Services; + +public interface IMultiTimeframeCandleAggregator +{ + /// + /// Initializes historical ring buffers for an ISIN with Yahoo/database candles. + /// + void InitializeHistory(string isin, string timeframe, IEnumerable candles); + + /// + /// Processes an incoming clean tick and updates 1m, 5m, 15m, 1h, and 1d candles. + /// + void ProcessTick(CleanLiveTick tick); + + /// + /// Gets a snapshot of the ring buffer for an ISIN and timeframe. + /// + IReadOnlyList GetCandles(string isin, string timeframe); + + /// + /// Gets all multi-timeframe candles (1m, 5m, 15m, 1h, 1d) as a dictionary. + /// + Dictionary> GetAllTimeframes(string isin); + + /// + /// Event triggered when a timeframe bar completes. + /// + event Action? OnCandleClosed; +} + +public class MultiTimeframeCandleAggregator : IMultiTimeframeCandleAggregator +{ + private readonly IFinlyticLogger _logger; + private readonly ConcurrentDictionary>> _buffers = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _current1mCandles = new(StringComparer.OrdinalIgnoreCase); + private readonly object _aggregationLock = new(); + + public event Action? OnCandleClosed; + + public MultiTimeframeCandleAggregator(IFinlyticLogger logger) + { + _logger = logger; + } + + public void InitializeHistory(string isin, string timeframe, IEnumerable candles) + { + if (string.IsNullOrWhiteSpace(isin) || string.IsNullOrWhiteSpace(timeframe)) return; + + var cleanIsin = isin.Trim().ToUpperInvariant(); + var cleanTf = timeframe.Trim().ToLowerInvariant(); + + var isinBuffers = _buffers.GetOrAdd(cleanIsin, _ => new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase)); + var ringBuffer = isinBuffers.GetOrAdd(cleanTf, _ => new CircularRingBuffer(500)); + + var ordered = candles + .Where(c => c.Close > 0m) + .OrderBy(c => c.Timestamp) + .ToList(); + + ringBuffer.LoadBulk(ordered); + } + + public void ProcessTick(CleanLiveTick tick) + { + if (tick == null || string.IsNullOrWhiteSpace(tick.Isin)) return; + + var isin = tick.Isin.Trim().ToUpperInvariant(); + var tickTime = tick.TimestampUtc; + var minuteBoundary = new DateTime(tickTime.Year, tickTime.Month, tickTime.Day, tickTime.Hour, tickTime.Minute, 0, DateTimeKind.Utc); + + lock (_aggregationLock) + { + var isinBuffers = _buffers.GetOrAdd(isin, _ => new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase)); + var ringBuffer1m = isinBuffers.GetOrAdd("1m", _ => new CircularRingBuffer(500)); + + if (_current1mCandles.TryGetValue(isin, out var current1m)) + { + if (current1m.Timestamp == minuteBoundary) + { + // Update current open 1m bar + var updated = current1m with + { + High = Math.Max(current1m.High, tick.MidPrice), + Low = Math.Min(current1m.Low, tick.MidPrice), + Close = tick.MidPrice, + Volume = current1m.Volume + 1, + Bid = tick.Bid, + Ask = tick.Ask + }; + _current1mCandles[isin] = updated; + ringBuffer1m.UpdateLast(updated); + } + else if (minuteBoundary > current1m.Timestamp) + { + // 1. Close current 1m bar + ringBuffer1m.UpdateLast(current1m); + OnCandleClosed?.Invoke(isin, "1m", current1m); + + // 2. Reconnect-Lückenbehandlung (Gap Handling) + // If multiple minutes passed without ticks (e.g. disconnect), fill gaps flatly with Volume = 0 + var gapStart = current1m.Timestamp.AddMinutes(1); + var lastClose = current1m.Close; + while (gapStart < minuteBoundary) + { + var flatBar = new CandleDto( + Timestamp: gapStart, + Open: lastClose, + High: lastClose, + Low: lastClose, + Close: lastClose, + Volume: 0, + Bid: tick.Bid, + Ask: tick.Ask + ); + ringBuffer1m.Add(flatBar); + OnCandleClosed?.Invoke(isin, "1m", flatBar); + gapStart = gapStart.AddMinutes(1); + } + + // 3. Start new 1m bar + var new1m = new CandleDto( + Timestamp: minuteBoundary, + Open: tick.MidPrice, + High: tick.MidPrice, + Low: tick.MidPrice, + Close: tick.MidPrice, + Volume: 1, + Bid: tick.Bid, + Ask: tick.Ask + ); + _current1mCandles[isin] = new1m; + ringBuffer1m.Add(new1m); + + // 4. Update higher timeframes (5m, 15m, 1h, 1d) + RebuildHigherTimeframes(isin, isinBuffers, ringBuffer1m); + } + } + else + { + // First tick for this ISIN + var new1m = new CandleDto( + Timestamp: minuteBoundary, + Open: tick.MidPrice, + High: tick.MidPrice, + Low: tick.MidPrice, + Close: tick.MidPrice, + Volume: 1, + Bid: tick.Bid, + Ask: tick.Ask + ); + _current1mCandles[isin] = new1m; + ringBuffer1m.Add(new1m); + } + } + } + + private void RebuildHigherTimeframes(string isin, ConcurrentDictionary> isinBuffers, CircularRingBuffer ringBuffer1m) + { + var snapshot1m = ringBuffer1m.ToArray(); + if (snapshot1m.Length == 0) return; + + // Build 5m candles + AggregatePeriod(isin, isinBuffers, snapshot1m, "5m", 5); + + // Build 15m candles + AggregatePeriod(isin, isinBuffers, snapshot1m, "15m", 15); + + // Build 1h candles + AggregatePeriod(isin, isinBuffers, snapshot1m, "1h", 60); + + // Build 1d candles + AggregateDaily(isin, isinBuffers, snapshot1m); + } + + private void AggregatePeriod(string isin, ConcurrentDictionary> isinBuffers, CandleDto[] candles1m, string tfName, int minutes) + { + var targetBuffer = isinBuffers.GetOrAdd(tfName, _ => new CircularRingBuffer(500)); + targetBuffer.LoadBulk(FinlyticTechnicals.Indicators.CandleResampler.Resample(candles1m, minutes)); + } + + private void AggregateDaily(string isin, ConcurrentDictionary> isinBuffers, CandleDto[] candles1m) + { + var targetBuffer = isinBuffers.GetOrAdd("1d", _ => new CircularRingBuffer(500)); + var aggregated = FinlyticTechnicals.Indicators.CandleResampler.Resample(candles1m, 1440); + + // If daily buffer already has deep Yahoo history, stitch today's aggregated bar onto the end + if (targetBuffer.Count > 0 && aggregated.Count > 0) + { + var today = aggregated.Last(); + var lastHistory = targetBuffer.GetLast(); + if (lastHistory != null && lastHistory.Timestamp.Date == today.Timestamp.Date) + { + targetBuffer.UpdateLast(today); + } + else + { + targetBuffer.Add(today); + } + } + else if (aggregated.Count > 0) + { + targetBuffer.LoadBulk(aggregated); + } + } + + public IReadOnlyList GetCandles(string isin, string timeframe) + { + if (string.IsNullOrWhiteSpace(isin)) return []; + var cleanIsin = isin.Trim().ToUpperInvariant(); + var cleanTf = (timeframe ?? "15m").Trim().ToLowerInvariant(); + + if (_buffers.TryGetValue(cleanIsin, out var isinBuffers) && + isinBuffers.TryGetValue(cleanTf, out var ringBuffer)) + { + return ringBuffer.ToArray(); + } + return []; + } + + public Dictionary> GetAllTimeframes(string isin) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(isin)) return result; + + var cleanIsin = isin.Trim().ToUpperInvariant(); + if (_buffers.TryGetValue(cleanIsin, out var isinBuffers)) + { + foreach (var kvp in isinBuffers) + { + result[kvp.Key] = kvp.Value.ToArray(); + } + } + return result; + } +} diff --git a/FinlyticTechnicals/Services/TechnicalScannerBackgroundService.cs b/FinlyticTechnicals/Services/TechnicalScannerBackgroundService.cs new file mode 100644 index 0000000..df70780 --- /dev/null +++ b/FinlyticTechnicals/Services/TechnicalScannerBackgroundService.cs @@ -0,0 +1,118 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Services; +using FinlyticTechnicals.Util; +using Microsoft.Extensions.Hosting; + +namespace FinlyticTechnicals.Services; + +public class TechnicalScannerBackgroundService : BackgroundService +{ + private readonly ITechnicalUniverseManager _universeManager; + private readonly ITechnicalScoringEngine _scoringEngine; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public TechnicalScannerBackgroundService( + ITechnicalUniverseManager universeManager, + ITechnicalScoringEngine scoringEngine, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _universeManager = universeManager; + _scoringEngine = scoringEngine; + _settingsService = settingsService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[TechnicalScanner] Starting Technical Universe Scanner Background Service..."); + + // Initial delay for MQTT connections to stabilize + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + // Initial synchronization of Universe + await _universeManager.RefreshFavoritesAsync(stoppingToken); + await _universeManager.RefreshDiscoveryAsync(stoppingToken); + + DateTime lastFavoritesSyncUtc = DateTime.UtcNow; + DateTime lastDiscoverySyncUtc = DateTime.UtcNow; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + DateTime now = DateTime.UtcNow; + + // 1. Check Periodic Sync Timers + if (now - lastFavoritesSyncUtc >= TimeSpan.FromMinutes(15)) + { + await _universeManager.RefreshFavoritesAsync(stoppingToken); + lastFavoritesSyncUtc = DateTime.UtcNow; + } + + if (now - lastDiscoverySyncUtc >= TimeSpan.FromMinutes(30)) + { + await _universeManager.RefreshDiscoveryAsync(stoppingToken); + lastDiscoverySyncUtc = DateTime.UtcNow; + } + + // 2. Retrieve Active Prioritized Scan Universe + var universe = await _universeManager.GetActiveUniverseAsync(stoppingToken); + if (universe.Count > 0) + { + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[TechnicalScanner] Scanning {Count} assets in active universe across all strategies...", universe.Count); + + foreach (var entry in universe) + { + if (stoppingToken.IsCancellationRequested) break; + + try + { + var setups = await _scoringEngine.AnalyzeIsinAsync(entry.Isin, entry.Symbol, entry.Source, entry.AddedAtUtc, stoppingToken); + if (setups.Count > 0) + { + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[TechnicalScanner] Found {Count} active setup(s) for ISIN {Isin} (Top Score: {Score:F1})", + setups.Count, entry.Isin, setups[0].QualityScore); + } + } + catch (Exception ex) + { + await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, + "[TechnicalScanner] Error analyzing ISIN {Isin}", entry.Isin); + } + + // Gentle throttle between asset analysis runs + await Task.Delay(250, stoppingToken); + } + } + else + { + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[TechnicalScanner] Scan universe is currently empty. Waiting for next cycle."); + } + + // Wait 60 seconds before next full universe evaluation pass + await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + await _logger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, + "[TechnicalScanner] Unexpected error in scanner loop. Retrying in 30 seconds."); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[TechnicalScanner] Technical Universe Scanner Background Service stopped."); + } +} diff --git a/FinlyticTechnicals/Services/TechnicalScoringEngine.cs b/FinlyticTechnicals/Services/TechnicalScoringEngine.cs new file mode 100644 index 0000000..11449de --- /dev/null +++ b/FinlyticTechnicals/Services/TechnicalScoringEngine.cs @@ -0,0 +1,580 @@ +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; + +public interface ITechnicalScoringEngine +{ + /// + /// Evaluates technical setup, indicators, and patterns for an ISIN and returns trading setups. + /// + /// + /// Which universe-selection mechanism this ISIN is currently monitored under (favorite/discovery/ + /// sentiment-spike), if known - passed through onto the returned s and + /// persisted alongside them so downstream consumers (FinlyticEngine) can record why the asset was being + /// watched. for an ad hoc analysis outside the scan universe. + /// + /// When the ISIN entered that universe, alongside . + Task> AnalyzeIsinAsync(string isin, string? symbol = null, UniverseSource? universeSource = null, DateTime? universeEnteredAtUtc = null, CancellationToken cancellationToken = default); + + /// + /// Gets full technical analysis including candles, calculated indicators, patterns, and signals for an ISIN. + /// + Task GetTechnicalAnalysisDtoAsync(string isin, string? symbol = null, CancellationToken cancellationToken = default); + + /// + /// Gets all active top-pick setups from the database. + /// + Task> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default); + + /// + /// Returns the last setups persisted for across all scan + /// cycles, most recent first - regardless of IsActive/expiry/top-pick status, so a caller can see + /// the raw quality-score trend over time, including setups too weak to ever have reached the engine. + /// + Task> GetRecentSetupHistoryAsync(string isin, int limit = 8, CancellationToken cancellationToken = default); +} + +public class TechnicalScoringEngine : ITechnicalScoringEngine +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IMultiTimeframeCandleAggregator _aggregator; + private readonly IYahooMarketDataScraper _yahooScraper; + private readonly IEnumerable _patternDetectors; + private readonly IEnumerable _strategies; + private readonly IFinlyticLogger _logger; + + public TechnicalScoringEngine( + IServiceScopeFactory scopeFactory, + IMultiTimeframeCandleAggregator aggregator, + IYahooMarketDataScraper yahooScraper, + IEnumerable patternDetectors, + IEnumerable strategies, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _aggregator = aggregator; + _yahooScraper = yahooScraper; + _patternDetectors = patternDetectors; + _strategies = strategies; + _logger = logger; + } + + + public async Task> 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, "[TechnicalScoringEngine] 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 + 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(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(); + 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, "[TechnicalScoringEngine] Pattern detector {Detector} threw an exception for ISIN {Isin}", detector.GetType().Name, cleanIsin); + } + } + + // 4. Run all Strategies + var evaluatedSetups = new List(); + 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 = CalculateIndicatorConfluenceScore(indicators, setup.Direction); + decimal patternScore = detectedPatterns.Count > 0 ? detectedPatterns.Average(p => p.QualityScore) : 50m; + 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, "[TechnicalScoringEngine] 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; + } + + private decimal CalculateIndicatorConfluenceScore(Dictionary ind, SignalDirection dir) + { + decimal score = 50m; + if (dir == SignalDirection.Buy) + { + if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 > e50) score += 15m; + if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 45m and <= 65m) score += 15m; + if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m; + if (ind.TryGetValue("VWAP", out var vwap) && ind.TryGetValue("EMA_20", out var e20b) && e20b > vwap) score += 10m; + } + else if (dir == SignalDirection.Sell) + { + if (ind.TryGetValue("EMA_20", out var e20) && ind.TryGetValue("EMA_50", out var e50) && e20 < e50) score += 15m; + if (ind.TryGetValue("RSI_14", out var rsi) && rsi is >= 35m and <= 55m) score += 15m; + if (ind.TryGetValue("ADX_14", out var adx) && adx >= 25m) score += 10m; + } + return Math.Clamp(score, 0m, 100m); + } + + private async Task PersistResultsAsync(string isin, string symbol, List patterns, List setups) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // 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, "[TechnicalScoringEngine] Error persisting patterns & setups for ISIN {Isin}", isin); + } + } + + public async Task> GetActiveSetupsAsync(bool topPicksOnly = false, int limit = 50, decimal? minScore = null, CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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 && minScore.Value > 0) + { + query = query.Where(s => s.QualityScore >= minScore.Value); + } + + var entities = await query + .OrderByDescending(s => s.QualityScore) + .Take(limit) + .ToListAsync(cancellationToken); + + + return entities.Select(e => new StrategyResultDto( + SetupId: e.SetupId, + Isin: e.Isin, + Symbol: e.Symbol, + Timeframe: e.Timeframe, + StrategyKey: e.StrategyKey, + StrategyName: e.StrategyName, + Direction: Enum.TryParse(e.Direction, out var dir) ? dir : SignalDirection.Buy, + QualityScore: e.QualityScore, + CurrentPrice: e.CurrentPrice, + EntryPrice: e.EntryPrice, + InvalidationPrice: e.InvalidationPrice, + CurrentAtr: e.CurrentAtr, + EstimatedRiskRewardRatio: e.EstimatedRiskRewardRatio, + ExitPlan: e.ExitPlan, + TechnicalRationale: e.TechnicalRationale, + TriggeringPatterns: e.TriggeringPatterns ?? [], + IndicatorSnapshot: e.IndicatorSnapshot ?? [], + CreatedAt: e.CreatedAtUtc, + ExpiresAt: e.ExpiresAtUtc, + IsTopPick: e.IsTopPick, + Rating: e.Rating, + UniverseSource: Enum.TryParse(e.UniverseSource, out var universeSource) ? universeSource : null, + UniverseEnteredAtUtc: e.UniverseEnteredAtUtc, + Regime: Enum.TryParse(e.Regime, out var regimeParsed) ? regimeParsed : null + )).ToList(); + } + + public async Task> 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(); + + var entities = await db.FtaTechnicalSetups + .AsNoTracking() + .Where(s => s.Isin == cleanIsin) + .OrderByDescending(s => s.CreatedAtUtc) + .Take(limit) + .ToListAsync(cancellationToken); + + return entities.Select(e => new StrategyResultDto( + SetupId: e.SetupId, + Isin: e.Isin, + Symbol: e.Symbol, + Timeframe: e.Timeframe, + StrategyKey: e.StrategyKey, + StrategyName: e.StrategyName, + Direction: Enum.TryParse(e.Direction, out var dir) ? dir : SignalDirection.Buy, + QualityScore: e.QualityScore, + CurrentPrice: e.CurrentPrice, + EntryPrice: e.EntryPrice, + InvalidationPrice: e.InvalidationPrice, + CurrentAtr: e.CurrentAtr, + EstimatedRiskRewardRatio: e.EstimatedRiskRewardRatio, + ExitPlan: e.ExitPlan, + TechnicalRationale: e.TechnicalRationale, + TriggeringPatterns: e.TriggeringPatterns ?? [], + IndicatorSnapshot: e.IndicatorSnapshot ?? [], + CreatedAt: e.CreatedAtUtc, + ExpiresAt: e.ExpiresAtUtc, + IsTopPick: e.IsTopPick, + Rating: e.Rating, + UniverseSource: Enum.TryParse(e.UniverseSource, out var universeSource) ? universeSource : null, + UniverseEnteredAtUtc: e.UniverseEnteredAtUtc, + Regime: Enum.TryParse(e.Regime, out var regimeParsed) ? regimeParsed : null + )).ToList(); + } + + public async Task 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(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(); + foreach (var detector in _patternDetectors) + { + try + { + var pat = detector.Evaluate(context); + if (pat != null) + { + detectedPatterns.Add(pat); + } + } + catch { } + } + + var indicatorList = new List(); + 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 { 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 { 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" + ); + } +} diff --git a/FinlyticTechnicals/Services/TechnicalUniverseManager.cs b/FinlyticTechnicals/Services/TechnicalUniverseManager.cs new file mode 100644 index 0000000..bc5e7a3 --- /dev/null +++ b/FinlyticTechnicals/Services/TechnicalUniverseManager.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Assets; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Models.Assets; +using FinlyticCore.Services; +using FinlyticTechnicals.Database; +using FinlyticTechnicals.Entities; +using FinlyticTechnicals.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FinlyticTechnicals.Services; + +public record MonitoredUniverseEntry( + string Isin, + string? Symbol, + UniverseSource Source, + DateTime AddedAtUtc, + DateTime? ExpiresAtUtc, + int Priority +); + +public interface ITechnicalUniverseManager +{ + Task AddOrUpdateAssetAsync(string isin, string? symbol, UniverseSource source, int priority, TimeSpan? ttl = null, CancellationToken cancellationToken = default); + Task RemoveExpiredAsync(CancellationToken cancellationToken = default); + Task> GetActiveUniverseAsync(CancellationToken cancellationToken = default); + + /// + /// Looks up the current universe entry for a single ISIN, if it is currently monitored. Used by + /// TAMqttClient to attach / + /// onto an on-demand ta_GetSetupsForIsin analysis, so the caller (FinlyticEngine) can record why the + /// asset was being watched in the first place. Returns if the ISIN is not currently + /// in the universe (e.g. a manual "Analyze now" call for an asset nobody favorited/discovered/spiked). + /// + Task GetEntryAsync(string isin, CancellationToken cancellationToken = default); + Task RefreshFavoritesAsync(CancellationToken cancellationToken = default); + Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default); +} + +/// +/// Maintains the prioritized set of ISINs FinlyticTechnicals continuously scans (favorites aggregated across +/// all users, FinlyticAssets' curated discovery list, and temporary sentiment-spike promotions), backed by the +/// fta_monitored_universe_assets table rather than an in-memory collection so the current universe is +/// inspectable in the database while the service is running. The table is deliberately wiped on every service +/// startup (see Program.cs) - it is fully rebuilt within minutes from +/// / and fresh sentiment-spike events, so +/// nothing of value would survive a restart anyway. +/// +public class TechnicalUniverseManager : ITechnicalUniverseManager +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ITAMqttRpcClient _rpcClient; + private readonly IFinlyticLogger _logger; + + public TechnicalUniverseManager( + IServiceScopeFactory scopeFactory, + ITAMqttRpcClient rpcClient, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _rpcClient = rpcClient; + _logger = logger; + } + + public async Task AddOrUpdateAssetAsync(string isin, string? symbol, UniverseSource source, int priority, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(isin)) return; + + var cleanIsin = isin.Trim().ToUpperInvariant(); + DateTime now = DateTime.UtcNow; + DateTime? expiresAt = ttl.HasValue ? now.Add(ttl.Value) : null; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var existing = await db.MonitoredUniverseAssets.FirstOrDefaultAsync(e => e.Isin == cleanIsin, cancellationToken); + if (existing == null) + { + db.MonitoredUniverseAssets.Add(new FtaMonitoredUniverseAssetEntity + { + Isin = cleanIsin, + Symbol = symbol, + Source = source.ToString(), + Priority = priority, + AddedAtUtc = now, + ExpiresAtUtc = expiresAt + }); + } + else + { + // Keep the highest priority (lower int value = higher priority), matching the original in-memory + // ConcurrentDictionary.AddOrUpdate semantics this table replaced. + int bestPriority = Math.Min(existing.Priority, priority); + if (bestPriority == priority) + { + existing.Source = source.ToString(); + } + existing.Priority = bestPriority; + existing.Symbol = symbol ?? existing.Symbol; + existing.ExpiresAtUtc = expiresAt != null && (existing.ExpiresAtUtc == null || expiresAt > existing.ExpiresAtUtc) + ? expiresAt + : existing.ExpiresAtUtc; + } + + await db.SaveChangesAsync(cancellationToken); + + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[UniverseManager] Added/Updated asset {Isin} (Source: {Source}, Priority: {Priority}, TTL: {TTL}m)", + cleanIsin, source, priority, ttl?.TotalMinutes ?? 0); + } + + public async Task RemoveExpiredAsync(CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + DateTime now = DateTime.UtcNow; + var expired = await db.MonitoredUniverseAssets + .Where(e => e.ExpiresAtUtc.HasValue && e.ExpiresAtUtc.Value <= now) + .ToListAsync(cancellationToken); + + if (expired.Count == 0) return; + + db.MonitoredUniverseAssets.RemoveRange(expired); + await db.SaveChangesAsync(cancellationToken); + + foreach (var removed in expired) + { + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[UniverseManager] Expired temporary asset {Isin} (Source: {Source}) removed from scan universe.", + removed.Isin, removed.Source); + } + } + + public async Task> GetActiveUniverseAsync(CancellationToken cancellationToken = default) + { + await RemoveExpiredAsync(cancellationToken); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var entities = await db.MonitoredUniverseAssets + .AsNoTracking() + .OrderBy(e => e.Priority) + .ThenByDescending(e => e.AddedAtUtc) + .ToListAsync(cancellationToken); + + return entities.Select(ToEntry).ToList(); + } + + public async Task GetEntryAsync(string isin, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + var cleanIsin = isin.Trim().ToUpperInvariant(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var entity = await db.MonitoredUniverseAssets.AsNoTracking().FirstOrDefaultAsync(e => e.Isin == cleanIsin, cancellationToken); + return entity == null ? null : ToEntry(entity); + } + + public async Task RefreshFavoritesAsync(CancellationToken cancellationToken = default) + { + try + { + var isins = await _rpcClient.SendRpcRequestAsync, string>( + "backend_GetAggregatedFavorites", + string.Empty, + TimeSpan.FromSeconds(5) + ); + + var freshSet = ToCleanIsinSet(isins); + int prunedCount = await UpsertSourceBatchAsync(UniverseSource.UserFavorite, priority: 2, freshSet, cancellationToken); + + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[UniverseManager] Synced {Count} user favorite ISINs from FinlyticBackend ({Pruned} stale entries pruned).", + freshSet.Count, prunedCount); + } + catch (Exception ex) + { + await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, + "[UniverseManager] Failed to refresh user favorites from FinlyticBackend via RPC."); + } + } + + public async Task RefreshDiscoveryAsync(CancellationToken cancellationToken = default) + { + try + { + var req = new GetDiscoveryAssetsRequest(Limit: 35); + var discoveryAssets = await _rpcClient.SendRpcRequestAsync, GetDiscoveryAssetsRequest>( + "assets_GetDiscovery", + req, + TimeSpan.FromSeconds(5) + ); + + var freshSet = ToCleanIsinSet(discoveryAssets?.Select(a => a.Isin)); + int prunedCount = await UpsertSourceBatchAsync(UniverseSource.Discovery, priority: 3, freshSet, cancellationToken); + + await _logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[UniverseManager] Synced {Count} discovery assets from FinlyticAssets ({Pruned} stale entries pruned).", + freshSet.Count, prunedCount); + } + catch (Exception ex) + { + await _logger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, + "[UniverseManager] Failed to refresh discovery assets from FinlyticAssets via RPC."); + } + } + + private static HashSet ToCleanIsinSet(IEnumerable? isins) + { + return new HashSet( + (isins ?? []).Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => i.Trim().ToUpperInvariant()), + StringComparer.OrdinalIgnoreCase); + } + + private static MonitoredUniverseEntry ToEntry(FtaMonitoredUniverseAssetEntity e) => new( + e.Isin, e.Symbol, + Enum.TryParse(e.Source, out var src) ? src : UniverseSource.Discovery, + e.AddedAtUtc, e.ExpiresAtUtc, e.Priority + ); + + /// + /// Upserts every ISIN in under / + /// in a single batch, and removes rows still tagged with whose ISIN is no longer + /// present in - i.e. an asset the latest refresh no longer reports (a user + /// unfavorited it, or it dropped out of discovery). A row that meanwhile got promoted to a different source + /// (e.g. a live sentiment spike) is left alone: its Source column no longer matches, so it survives on its + /// own TTL instead of being pruned here. Returns the number of stale rows pruned. + /// + private async Task UpsertSourceBatchAsync(UniverseSource source, int priority, HashSet freshIsins, CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + var sourceTag = source.ToString(); + + var all = await db.MonitoredUniverseAssets.ToListAsync(cancellationToken); + var byIsin = all.ToDictionary(e => e.Isin, e => e, StringComparer.OrdinalIgnoreCase); + + foreach (var isin in freshIsins) + { + if (byIsin.TryGetValue(isin, out var existing)) + { + int bestPriority = Math.Min(existing.Priority, priority); + if (bestPriority == priority) + { + existing.Source = sourceTag; + } + existing.Priority = bestPriority; + } + else + { + db.MonitoredUniverseAssets.Add(new FtaMonitoredUniverseAssetEntity + { + Isin = isin, + Symbol = null, + Source = sourceTag, + Priority = priority, + AddedAtUtc = now, + ExpiresAtUtc = null + }); + } + } + + var stale = all.Where(e => e.Source == sourceTag && !freshIsins.Contains(e.Isin)).ToList(); + if (stale.Count > 0) + { + db.MonitoredUniverseAssets.RemoveRange(stale); + } + + await db.SaveChangesAsync(cancellationToken); + return stale.Count; + } +} diff --git a/FinlyticTechnicals/Services/TradeRepublicIngestionService.cs b/FinlyticTechnicals/Services/TradeRepublicIngestionService.cs new file mode 100644 index 0000000..36d7f88 --- /dev/null +++ b/FinlyticTechnicals/Services/TradeRepublicIngestionService.cs @@ -0,0 +1,148 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; +using FinlyticTechnicals.Util; + +namespace FinlyticTechnicals.Services; + +/// +/// Cleaned, normalized real-time tick ready for multi-timeframe aggregation. +/// +public record CleanLiveTick( + string Isin, + decimal MidPrice, + decimal Bid, + decimal Ask, + decimal LastPrice, + decimal SpreadPercent, + bool IsSpreadVolatile, + DateTime TimestampUtc +); + +public interface ITradeRepublicIngestionService +{ + /// + /// Event triggered when a cleaned, UTC-normalized tick arrives. + /// + event Func? OnTickReceived; + + /// + /// Processes a raw tick from Trade Republic (e.g. via WebSocket or Poller). + /// + Task ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default); +} + +public class TradeRepublicIngestionService : ITradeRepublicIngestionService +{ + private readonly IFinlyticLogger _finlyticLogger; + private static readonly TimeZoneInfo BerlinTimeZone = GetBerlinTimeZone(); + + public event Func? OnTickReceived; + + public TradeRepublicIngestionService(IFinlyticLogger finlyticLogger) + { + _finlyticLogger = finlyticLogger; + } + + /// + /// Processes a raw incoming tick with strict UTC normalization, spread check, and mid-price calculation. + /// + public async Task ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + + var cleanIsin = isin.Trim().ToUpperInvariant(); + + // 1. Strict UTC Normalization + DateTime utcTimestamp; + if (timestamp.HasValue) + { + var rawTime = timestamp.Value; + if (rawTime.Kind == DateTimeKind.Utc) + { + utcTimestamp = rawTime; + } + else if (rawTime.Kind == DateTimeKind.Unspecified) + { + // Trade Republic ticks typically arrive in German local market time (Europe/Berlin) + utcTimestamp = TimeZoneInfo.ConvertTimeToUtc(rawTime, BerlinTimeZone); + } + else + { + utcTimestamp = rawTime.ToUniversalTime(); + } + } + else + { + utcTimestamp = DateTime.UtcNow; + } + + // 2. Clean Mid-Price Calculation: (Bid + Ask) / 2 + decimal cleanMidPrice; + if (bid > 0m && ask > 0m) + { + cleanMidPrice = (bid + ask) / 2m; + } + else if (last.HasValue && last.Value > 0m) + { + cleanMidPrice = last.Value; + if (bid <= 0m) bid = cleanMidPrice; + if (ask <= 0m) ask = cleanMidPrice; + } + else + { + return null; // Invalid quote + } + + // 3. Spread Calculation & Volatility Tagging + decimal spreadPercent = 0m; + bool isSpreadVolatile = false; + if (cleanMidPrice > 0m && ask >= bid) + { + spreadPercent = ((ask - bid) / cleanMidPrice) * 100m; + if (spreadPercent > 1.5m) + { + isSpreadVolatile = true; + } + } + + var cleanTick = new CleanLiveTick( + Isin: cleanIsin, + MidPrice: cleanMidPrice, + Bid: bid, + Ask: ask, + LastPrice: last ?? cleanMidPrice, + SpreadPercent: spreadPercent, + IsSpreadVolatile: isSpreadVolatile, + TimestampUtc: utcTimestamp + ); + + if (OnTickReceived != null) + { + await OnTickReceived.Invoke(cleanTick); + } + + return cleanTick; + } + + private static TimeZoneInfo GetBerlinTimeZone() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time"); // Windows ID + } + catch + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // Linux IANA ID + } + catch + { + return TimeZoneInfo.Utc; + } + } + } +} diff --git a/FinlyticTechnicals/Services/YahooMarketDataScraper.cs b/FinlyticTechnicals/Services/YahooMarketDataScraper.cs new file mode 100644 index 0000000..1ec5f9c --- /dev/null +++ b/FinlyticTechnicals/Services/YahooMarketDataScraper.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; +using FinlyticCore.Services.Yahoo; +using FinlyticTechnicals.Util; +using Microsoft.Extensions.Configuration; + +namespace FinlyticTechnicals.Services; + +public record YahooCandlesResult( + List Candles, + string Currency +); + +public interface IYahooMarketDataScraper +{ + /// + /// Resolves ticker from ISIN using Yahoo Search API. + /// + Task ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default); + + /// + /// Fetches historical candles with strict UTC timestamps. + /// + Task> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default); + + /// + /// Fetches historical candles with currency metadata. + /// + Task FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default); +} + +public class YahooMarketDataScraper : IYahooMarketDataScraper +{ + private readonly YahooFinanceClient _yahooClient; + private readonly IConfiguration _configuration; + private readonly IFinlyticLogger _finlyticLogger; + + public YahooMarketDataScraper( + YahooFinanceClient yahooClient, + IConfiguration configuration, + IFinlyticLogger finlyticLogger) + { + _yahooClient = yahooClient; + _configuration = configuration; + _finlyticLogger = finlyticLogger; + } + + public async Task ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + + var cleanIsin = isin.Trim().ToUpperInvariant(); + if (cleanIsin.Contains('.')) + { + return cleanIsin; + } + + if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase)) + { + var (cryptoSubtitle, cryptoName) = await FinlyticCore.Util.CryptoSubtitleResolver.ResolveCryptoInfoAsync( + cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken); + + if (!string.IsNullOrWhiteSpace(cryptoSubtitle)) + { + var candidates = new[] { $"{cryptoSubtitle}-EUR", $"{cryptoSubtitle}-USD", cryptoSubtitle }; + foreach (var candidate in candidates) + { + try + { + var res = await FetchHistoricalCandlesWithCurrencyAsync(candidate, "5d", "1d", cancellationToken); + if (res.Candles.Count > 0) + { + await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", cleanIsin, candidate, cryptoSubtitle); + return candidate; + } + } + catch { } + } + + return $"{cryptoSubtitle}-EUR"; + } + } + + try + { + var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken); + if (searchResult?.Quotes != null && searchResult.Quotes.Count > 0) + { + var prioritizedSuffixes = new[] { ".DE", ".F", ".SG", ".MU", ".BE", ".DU", ".HM" }; + + foreach (var suffix in prioritizedSuffixes) + { + var match = searchResult.Quotes.FirstOrDefault(q => + !string.IsNullOrWhiteSpace(q.Symbol) && + q.Symbol.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved ISIN {Isin} to German ticker {Symbol}", cleanIsin, match.Symbol); + return match.Symbol; + } + } + + var defaultQuote = searchResult.Quotes.FirstOrDefault(q => !string.IsNullOrWhiteSpace(q.Symbol)); + if (defaultQuote != null) + { + await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved ISIN {Isin} to primary ticker {Symbol}", cleanIsin, defaultQuote.Symbol); + return defaultQuote.Symbol; + } + } + } + catch (Exception ex) + { + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Search failed for ISIN {Isin}", cleanIsin); + } + + return null; + } + + public async Task> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default) + { + var result = await FetchHistoricalCandlesWithCurrencyAsync(symbol, range, interval, cancellationToken); + return result.Candles; + } + + public async Task FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default) + { + var results = new List(); + string detectedCurrency = FallbackCurrencyBySymbol(symbol); + + if (string.IsNullOrWhiteSpace(symbol)) return new YahooCandlesResult(results, detectedCurrency); + + try + { + var chartDto = await _yahooClient.GetChartAsync(symbol, range, interval, cancellationToken); + var resultObj = chartDto?.Chart?.Result?.FirstOrDefault(); + + if (resultObj == null) + { + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] No chart data returned from Yahoo Client for symbol {Symbol}", symbol); + return new YahooCandlesResult(results, detectedCurrency); + } + + if (!string.IsNullOrWhiteSpace(resultObj.Meta?.Currency)) + { + detectedCurrency = resultObj.Meta.Currency.ToUpperInvariant(); + } + + var timestamps = resultObj.Timestamp; + var quote = resultObj.Indicators?.Quote?.FirstOrDefault(); + + if (timestamps == null || quote == null || timestamps.Count == 0) + { + return new YahooCandlesResult(results, detectedCurrency); + } + + var opens = quote.Open ?? []; + var highs = quote.High ?? []; + var lows = quote.Low ?? []; + var closes = quote.Close ?? []; + var volumes = quote.Volume ?? []; + + for (int i = 0; i < timestamps.Count; i++) + { + // Strict UTC timestamp + var dt = DateTimeOffset.FromUnixTimeSeconds(timestamps[i]).UtcDateTime; + + var open = i < opens.Count && opens[i].HasValue ? (decimal)opens[i]!.Value : 0m; + var high = i < highs.Count && highs[i].HasValue ? (decimal)highs[i]!.Value : open; + var low = i < lows.Count && lows[i].HasValue ? (decimal)lows[i]!.Value : open; + var close = i < closes.Count && closes[i].HasValue ? (decimal)closes[i]!.Value : open; + var vol = i < volumes.Count && volumes[i].HasValue ? (long)volumes[i]!.Value : 0L; + + if (close <= 0m && open <= 0m) continue; + + results.Add(new CandleDto( + Timestamp: dt, + Open: open, + High: Math.Max(high, Math.Max(open, close)), + Low: Math.Min(low, Math.Min(open, close)), + Close: close, + Volume: vol + )); + } + + await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Successfully fetched {Count} candles for {Symbol} ({Range}, {Interval}, Currency: {Currency})", + results.Count, symbol, range, interval, detectedCurrency); + } + catch (Exception ex) + { + await _finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Error fetching historical candles for {Symbol}", symbol); + } + + return new YahooCandlesResult(results, detectedCurrency); + } + + private static string FallbackCurrencyBySymbol(string symbol) + { + if (string.IsNullOrWhiteSpace(symbol)) return "EUR"; + var s = symbol.Trim().ToUpperInvariant(); + if (s.EndsWith(".DE") || s.EndsWith(".F") || s.EndsWith(".PA") || s.EndsWith(".AS") || s.EndsWith(".MI")) + return "EUR"; + if (s.EndsWith(".L")) + return "GBp"; + return "USD"; + } +} \ No newline at end of file diff --git a/FinlyticTechnicals/Strategies/CoreStrategies.cs b/FinlyticTechnicals/Strategies/CoreStrategies.cs new file mode 100644 index 0000000..3438def --- /dev/null +++ b/FinlyticTechnicals/Strategies/CoreStrategies.cs @@ -0,0 +1,1122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticTechnicals.Indicators; + +namespace FinlyticTechnicals.Strategies; + +/// +/// 1. Trend Pullback into Fair Value Gap with Staged Scale-Out & Free-Roll Break-Even exit. +/// +public class TrendPullbackFvgStrategy : ITechnicalStrategy +{ + public string StrategyKey => "TrendPullbackFvg"; + public string StrategyName => "Trend Pullback FVG Retracement"; + public int Priority => 1; + + public bool IsApplicable(MarketRegime regime) => + regime == MarketRegime.BullishTrending || regime == MarketRegime.BearishTrending; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + if (candles.Count < 30) return null; + + // Tunable for backtesting only (see TechnicalContext.ParameterOverrides doc comment) - defaults match + // this strategy's original hardcoded values, so live scanning behavior is unchanged. + int emaFastPeriod = (int)context.GetParameter(StrategyKey, "EmaFast", 20m); + int emaMidPeriod = (int)context.GetParameter(StrategyKey, "EmaMid", 50m); + int emaSlowPeriod = (int)context.GetParameter(StrategyKey, "EmaSlow", 200m); + decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.2m); + + var current = candles.Last(); + decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(candles, emaFastPeriod); + decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(candles, emaMidPeriod); + decimal ema200 = TechnicalIndicatorsEngine.CalculateEma(candles, emaSlowPeriod); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + + if (atr <= 0) return null; + + // Long Setup: Bullish Trend (EMA20 > EMA50 > EMA200) + Bullish FVG retracement. + bool isBullishTrend = ema20 > ema50 && ema50 > ema200 && current.Close > ema50; + var fvgBullish = activePatterns.FirstOrDefault(p => p.Type == PatternType.FairValueGapBullish && p.Bias == PatternBias.Bullish); + + if (isBullishTrend && fvgBullish != null) + { + decimal entry = current.Close; + decimal stopLoss = Math.Min(fvgBullish.InvalidationLevel, entry - (stopAtrMultiplier * atr)); + decimal risk = entry - stopLoss; + if (risk <= 0) return null; + + decimal tp1 = entry + (1.5m * risk); + decimal tp2 = entry + (3.0m * risk); + decimal rrr = (tp2 - entry) / risk; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, tp1, 0.50m, 1.5m, "TP1: Scale-out 50% & Trigger Break-Even"), + new TakeProfitStage(2, tp2, 0.30m, 3.0m, "TP2: Scale-out 30%"), + ], + BreakEvenRule: new BreakEvenRule( + Enabled: true, + TriggerPrice: tp1, + OffsetToCoverFees: entry + (risk * 0.05m) + ), + TrailingStopRule: new TrailingStopRule( + Type: TrailingStopType.AtrMultiplier, + Multiplier: 1.5m, + ActivationPrice: tp1, + IndicatorKey: "ATR_14" + ), + MaxHoldingBars: 50 + ); + + var triggers = new List { fvgBullish }; + var indicators = new Dictionary + { + ["EMA_20"] = ema20, + ["EMA_50"] = ema50, + ["EMA_200"] = ema200, + ["ATR_14"] = atr + }; + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Buy, + QualityScore: 88m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: rrr, + ExitPlan: exitPlan, + TechnicalRationale: $"Bullish trend alignment (EMA20 > EMA50 > EMA200) with retracement into 15m FVG zone [{fvgBullish.LowerBoundary:F2} - {fvgBullish.UpperBoundary:F2}].", + TriggeringPatterns: triggers, + IndicatorSnapshot: indicators, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(6), + IsTopPick: true, + Rating: "A+" + ); + } + + // Short Setup (mirror image): Bearish Trend (EMA20 < EMA50 < EMA200) + Bearish FVG retracement. + bool isBearishTrend = ema20 < ema50 && ema50 < ema200 && current.Close < ema50; + var fvgBearish = activePatterns.FirstOrDefault(p => p.Type == PatternType.FairValueGapBearish && p.Bias == PatternBias.Bearish); + + if (isBearishTrend && fvgBearish != null) + { + decimal entry = current.Close; + decimal stopLoss = Math.Max(fvgBearish.InvalidationLevel, entry + (stopAtrMultiplier * atr)); + decimal risk = stopLoss - entry; + if (risk <= 0) return null; + + decimal tp1 = entry - (1.5m * risk); + decimal tp2 = entry - (3.0m * risk); + decimal rrr = (entry - tp2) / risk; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, tp1, 0.50m, 1.5m, "TP1: Scale-out 50% & Trigger Break-Even"), + new TakeProfitStage(2, tp2, 0.30m, 3.0m, "TP2: Scale-out 30%"), + ], + BreakEvenRule: new BreakEvenRule( + Enabled: true, + TriggerPrice: tp1, + OffsetToCoverFees: entry - (risk * 0.05m) + ), + TrailingStopRule: new TrailingStopRule( + Type: TrailingStopType.AtrMultiplier, + Multiplier: 1.5m, + ActivationPrice: tp1, + IndicatorKey: "ATR_14" + ), + MaxHoldingBars: 50 + ); + + var triggers = new List { fvgBearish }; + var indicators = new Dictionary + { + ["EMA_20"] = ema20, + ["EMA_50"] = ema50, + ["EMA_200"] = ema200, + ["ATR_14"] = atr + }; + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Sell, + QualityScore: 88m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: rrr, + ExitPlan: exitPlan, + TechnicalRationale: $"Bearish trend alignment (EMA20 < EMA50 < EMA200) with retracement into 15m FVG zone [{fvgBearish.LowerBoundary:F2} - {fvgBearish.UpperBoundary:F2}].", + TriggeringPatterns: triggers, + IndicatorSnapshot: indicators, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(6), + IsTopPick: true, + Rating: "A+" + ); + } + + return null; + } +} + +/// +/// 2. Volatility Squeeze Breakout with Fixed Single Target (+2.0 ATR). +/// +public class VolatilitySqueezeStrategy : ITechnicalStrategy +{ + public string StrategyKey => "VolatilitySqueeze"; + public string StrategyName => "Bollinger/Keltner Squeeze Breakout"; + public int Priority => 2; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + if (candles.Count < 25) return null; + + decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.0m); + decimal targetAtrMultiplier = context.GetParameter(StrategyKey, "TargetAtrMultiplier", 2.0m); + + var current = candles.Last(); + var squeeze = TechnicalIndicatorsEngine.CalculateVolatilitySqueeze(candles); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + + if (atr <= 0) return null; + + // Fired Bullish: Squeeze fired out of compression with positive momentum + if (squeeze.SqueezeState == "FIRED_BULLISH" && squeeze.MomentumHistogram > 0) + { + decimal entry = current.Close; + decimal stopLoss = entry - (stopAtrMultiplier * atr); + decimal target = entry + (targetAtrMultiplier * atr); + decimal risk = entry - stopLoss; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.FixedSingleTarget, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, target, 1.00m, 2.0m, "Target: 100% exit at +2.0 ATR") + ], + MaxHoldingBars: 20 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Buy, + QualityScore: 84m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 2.0m, + ExitPlan: exitPlan, + TechnicalRationale: $"Bollinger compression inside Keltner Channels fired bullish momentum ({squeeze.MomentumHistogram:F3}).", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary { ["ATR_14"] = atr, ["SqueezeMomentum"] = squeeze.MomentumHistogram }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(4), + IsTopPick: true, + Rating: "A" + ); + } + + // Fired Bearish: Squeeze fired out of compression with negative momentum (mirror image of above). + if (squeeze.SqueezeState == "FIRED_BEARISH" && squeeze.MomentumHistogram < 0) + { + decimal entry = current.Close; + decimal stopLoss = entry + (stopAtrMultiplier * atr); + decimal target = entry - (targetAtrMultiplier * atr); + decimal risk = stopLoss - entry; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.FixedSingleTarget, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, target, 1.00m, 2.0m, "Target: 100% exit at -2.0 ATR") + ], + MaxHoldingBars: 20 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Sell, + QualityScore: 84m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 2.0m, + ExitPlan: exitPlan, + TechnicalRationale: $"Bollinger compression inside Keltner Channels fired bearish momentum ({squeeze.MomentumHistogram:F3}).", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary { ["ATR_14"] = atr, ["SqueezeMomentum"] = squeeze.MomentumHistogram }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(4), + IsTopPick: true, + Rating: "A" + ); + } + + return null; + } +} + +/// +/// 3. SMC Liquidity Sweep & Structural Flip with tight SL over the sweep wick. +/// +public class SmcLiquiditySweepStrategy : ITechnicalStrategy +{ + public string StrategyKey => "SmcLiquiditySweep"; + public string StrategyName => "Smart Money Liquidity Sweep & CHoCH"; + public int Priority => 3; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var sweepLow = activePatterns.FirstOrDefault(p => p.Type == PatternType.LiquiditySweepLow); + var choch = activePatterns.FirstOrDefault(p => p.Type == PatternType.ChangeOfCharacter && p.Bias == PatternBias.Bullish); + + var sweepHigh = activePatterns.FirstOrDefault(p => p.Type == PatternType.LiquiditySweepHigh); + var chochBearish = activePatterns.FirstOrDefault(p => p.Type == PatternType.ChangeOfCharacter && p.Bias == PatternBias.Bearish); + + decimal stopBufferPercent = context.GetParameter(StrategyKey, "StopBufferPercent", 0.2m); + + if (sweepLow != null || choch != null) + { + var candles = context.PrimaryCandles; + var current = candles.Last(); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + + decimal entry = current.Close; + decimal stopLoss = (sweepLow?.LowerBoundary ?? current.Low) * (1m - (stopBufferPercent / 100m)); + decimal risk = entry - stopLoss; + + if (risk <= 0) return null; + + decimal tp1 = entry + (2.0m * risk); + decimal tp2 = entry + (4.0m * risk); + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, tp1, 0.60m, 2.0m, "TP1: 60% Scale-Out & Instant Free-Roll"), + new TakeProfitStage(2, tp2, 0.40m, 4.0m, "TP2: 40% Final Target") + ], + BreakEvenRule: new BreakEvenRule(true, tp1, entry + (risk * 0.05m)), + MaxHoldingBars: 35 + ); + + var triggers = new List(); + if (sweepLow != null) triggers.Add(sweepLow); + if (choch != null) triggers.Add(choch); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Buy, + QualityScore: 91m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: (tp2 - entry) / risk, + ExitPlan: exitPlan, + TechnicalRationale: $"Institutional liquidity sweep below {sweepLow?.KeyPriceLevel:F2} followed by buyer absorption and structural rejection.", + TriggeringPatterns: triggers, + IndicatorSnapshot: new Dictionary { ["ATR_14"] = atr }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(5), + IsTopPick: true, + Rating: "A+" + ); + } + + // Mirror image: a sweep above a known high (stop-loss hunt against shorts/breakout buyers) followed by + // a bearish Change-of-Character - interpreted as institutional sellers absorbing that liquidity. + if (sweepHigh != null || chochBearish != null) + { + var candles = context.PrimaryCandles; + var current = candles.Last(); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + + decimal entry = current.Close; + decimal stopLoss = (sweepHigh?.UpperBoundary ?? current.High) * (1m + (stopBufferPercent / 100m)); + decimal risk = stopLoss - entry; + + if (risk <= 0) return null; + + decimal tp1 = entry - (2.0m * risk); + decimal tp2 = entry - (4.0m * risk); + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, tp1, 0.60m, 2.0m, "TP1: 60% Scale-Out & Instant Free-Roll"), + new TakeProfitStage(2, tp2, 0.40m, 4.0m, "TP2: 40% Final Target") + ], + BreakEvenRule: new BreakEvenRule(true, tp1, entry - (risk * 0.05m)), + MaxHoldingBars: 35 + ); + + var triggersBearish = new List(); + if (sweepHigh != null) triggersBearish.Add(sweepHigh); + if (chochBearish != null) triggersBearish.Add(chochBearish); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Sell, + QualityScore: 91m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: (entry - tp2) / risk, + ExitPlan: exitPlan, + TechnicalRationale: $"Institutional liquidity sweep above {sweepHigh?.KeyPriceLevel:F2} followed by seller absorption and structural rejection.", + TriggeringPatterns: triggersBearish, + IndicatorSnapshot: new Dictionary { ["ATR_14"] = atr }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(5), + IsTopPick: true, + Rating: "A+" + ); + } + + return null; + } +} + +/// +/// 4. Mean Reversion from 2.5-Sigma Bollinger Band in Rangebound markets. +/// +public class MeanReversionStrategy : ITechnicalStrategy +{ + public string StrategyKey => "MeanReversion"; + public string StrategyName => "Bollinger 2.5-Sigma Mean Reversion"; + public int Priority => 4; + + public bool IsApplicable(MarketRegime regime) => + regime == MarketRegime.LowVolatilityRangebound || regime == MarketRegime.HighVolatilityChoppy; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + if (candles.Count < 25) return null; + + decimal bollingerMultiplier = context.GetParameter(StrategyKey, "BollingerMultiplier", 2.5m); + decimal adxThreshold = context.GetParameter(StrategyKey, "AdxThreshold", 22m); + decimal rsiOversold = context.GetParameter(StrategyKey, "RsiOversold", 32m); + decimal rsiOverbought = context.GetParameter(StrategyKey, "RsiOverbought", 68m); + + var current = candles.Last(); + var bb = TechnicalIndicatorsEngine.CalculateBollingerBands(candles, 20, bollingerMultiplier); + decimal rsi = TechnicalIndicatorsEngine.CalculateRsi(candles, 14); + var adx = TechnicalIndicatorsEngine.CalculateAdx(candles, 14); + + // Rangebound with low ADX and oversold RSI touching the lower band + if (adx.Adx < adxThreshold && rsi <= rsiOversold && current.Low <= bb.LowerBand) + { + decimal entry = current.Close; + decimal vwapTarget = TechnicalIndicatorsEngine.CalculateVwap(candles); + if (vwapTarget <= entry) vwapTarget = bb.MiddleBand; + + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + decimal stopLoss = current.Low - (0.8m * atr); + decimal risk = entry - stopLoss; + + if (risk <= 0 || vwapTarget <= entry) return null; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.DynamicBandTouch, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, vwapTarget, 1.00m, (vwapTarget - entry) / risk, "Target: 100% Exit at VWAP / SMA20") + ], + MaxHoldingBars: 15 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Buy, + QualityScore: 79m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: (vwapTarget - entry) / risk, + ExitPlan: exitPlan, + TechnicalRationale: $"Oversold {bollingerMultiplier:F1}-sigma Bollinger stretch (RSI {rsi:F1}, ADX {adx.Adx:F1}) targeting mean reversion back to VWAP {vwapTarget:F2}.", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary + { + ["RSI_14"] = rsi, + ["ADX_14"] = adx.Adx, + ["BB_Lower"] = bb.LowerBand, + ["VWAP"] = vwapTarget + }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(3), + IsTopPick: false, + Rating: "B" + ); + } + + // Mirror image: rangebound with low ADX and overbought RSI touching the upper band. + if (adx.Adx < adxThreshold && rsi >= rsiOverbought && current.High >= bb.UpperBand) + { + decimal entry = current.Close; + decimal vwapTarget = TechnicalIndicatorsEngine.CalculateVwap(candles); + if (vwapTarget >= entry) vwapTarget = bb.MiddleBand; + + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + decimal stopLoss = current.High + (0.8m * atr); + decimal risk = stopLoss - entry; + + if (risk <= 0 || vwapTarget >= entry) return null; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.DynamicBandTouch, + InitialStopLoss: stopLoss, + TakeProfitStages: + [ + new TakeProfitStage(1, vwapTarget, 1.00m, (entry - vwapTarget) / risk, "Target: 100% Exit at VWAP / SMA20") + ], + MaxHoldingBars: 15 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Sell, + QualityScore: 79m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: (entry - vwapTarget) / risk, + TechnicalRationale: $"Overbought {bollingerMultiplier:F1}-sigma Bollinger stretch (RSI {rsi:F1}, ADX {adx.Adx:F1}) targeting mean reversion back to VWAP {vwapTarget:F2}.", + ExitPlan: exitPlan, + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary + { + ["RSI_14"] = rsi, + ["ADX_14"] = adx.Adx, + ["BB_Upper"] = bb.UpperBand, + ["VWAP"] = vwapTarget + }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(3), + IsTopPick: false, + Rating: "B" + ); + } + + return null; + } +} + +/// +/// 5. SuperTrend Multi-Timeframe Trend Follower with Pure Trailing Stop. +/// +public class SuperTrendMultiTfStrategy : ITechnicalStrategy +{ + public string StrategyKey => "SuperTrendMultiTf"; + public string StrategyName => "SuperTrend Multi-Timeframe Alignment"; + public int Priority => 5; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles15m = context.GetCandles("15m"); + var candles1h = context.GetCandles("1h"); + + if (candles15m.Count < 15 || candles1h.Count < 15) return null; + + int stPeriod = (int)context.GetParameter(StrategyKey, "Period", 10m); + decimal stMultiplier = context.GetParameter(StrategyKey, "Multiplier", 3.0m); + + var st1h = TechnicalIndicatorsEngine.CalculateSuperTrend(candles1h, stPeriod, stMultiplier); + var st15m = TechnicalIndicatorsEngine.CalculateSuperTrend(candles15m, stPeriod, stMultiplier); + + // Bullish Confluence: 1h SuperTrend is BUY and 15m SuperTrend just flipped to BUY or is bullish + if (st1h.Direction == SignalDirection.Buy && st15m.Direction == SignalDirection.Buy) + { + var current = candles15m.Last(); + decimal entry = current.Close; + decimal stopLoss = st15m.Value; + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles15m, 14); + + decimal risk = entry - stopLoss; + if (risk <= 0) return null; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.PureTrailingStop, + InitialStopLoss: stopLoss, + TakeProfitStages: [], + TrailingStopRule: new TrailingStopRule( + Type: TrailingStopType.SuperTrendLine, + Multiplier: stMultiplier, + ActivationPrice: entry, + IndicatorKey: "SuperTrend_15m" + ), + ReversalCondition: new ReversalCondition( + RuleDescription: "Exit immediately if 15m SuperTrend flips to Bearish", + IndicatorTrigger: "SuperTrend_15m_Flip_Sell" + ) + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: "15m", + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Buy, + QualityScore: 86m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 3.0m, + ExitPlan: exitPlan, + TechnicalRationale: $"1h macro SuperTrend and 15m micro SuperTrend in bullish confluence with dynamic trailing stop at {stopLoss:F2}.", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary + { + ["SuperTrend_1h"] = st1h.Value, + ["SuperTrend_15m"] = st15m.Value, + ["ATR_14"] = atr + }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(8), + IsTopPick: true, + Rating: "A" + ); + } + + // Bearish Confluence (mirror image): 1h SuperTrend is SELL and 15m SuperTrend is also bearish. + if (st1h.Direction == SignalDirection.Sell && st15m.Direction == SignalDirection.Sell) + { + var current = candles15m.Last(); + decimal entry = current.Close; + decimal stopLoss = st15m.Value; + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles15m, 14); + + decimal risk = stopLoss - entry; + if (risk <= 0) return null; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.PureTrailingStop, + InitialStopLoss: stopLoss, + TakeProfitStages: [], + TrailingStopRule: new TrailingStopRule( + Type: TrailingStopType.SuperTrendLine, + Multiplier: stMultiplier, + ActivationPrice: entry, + IndicatorKey: "SuperTrend_15m" + ), + ReversalCondition: new ReversalCondition( + RuleDescription: "Exit immediately if 15m SuperTrend flips to Bullish", + IndicatorTrigger: "SuperTrend_15m_Flip_Buy" + ) + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: "15m", + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: SignalDirection.Sell, + QualityScore: 86m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 3.0m, + ExitPlan: exitPlan, + TechnicalRationale: $"1h macro SuperTrend and 15m micro SuperTrend in bearish confluence with dynamic trailing stop at {stopLoss:F2}.", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary + { + ["SuperTrend_1h"] = st1h.Value, + ["SuperTrend_15m"] = st15m.Value, + ["ATR_14"] = atr + }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(8), + IsTopPick: true, + Rating: "A" + ); + } + + return null; + } +} + +/// +/// 6. MACD Signal Line Crossover - classic momentum-shift strategy. Fires when the MACD line crosses the +/// signal line (compared against the same calculation one bar earlier) with the histogram confirming direction. +/// +public class MacdCrossoverStrategy : ITechnicalStrategy +{ + public string StrategyKey => "MacdCrossover"; + public string StrategyName => "MACD Signal Line Crossover"; + public int Priority => 6; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + if (candles.Count < 40) return null; + + var current = candles.Last(); + var previousCandles = candles.Take(candles.Count - 1).ToList(); + if (previousCandles.Count < 35) return null; + + int fastPeriod = (int)context.GetParameter(StrategyKey, "FastPeriod", 12m); + int slowPeriod = (int)context.GetParameter(StrategyKey, "SlowPeriod", 26m); + int signalPeriod = (int)context.GetParameter(StrategyKey, "SignalPeriod", 9m); + decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.5m); + + var macdNow = TechnicalIndicatorsEngine.CalculateMacd(candles, fastPeriod, slowPeriod, signalPeriod); + var macdPrev = TechnicalIndicatorsEngine.CalculateMacd(previousCandles, fastPeriod, slowPeriod, signalPeriod); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + if (atr <= 0) return null; + + bool bullishCross = macdPrev.MacdLine <= macdPrev.SignalLine && macdNow.MacdLine > macdNow.SignalLine && macdNow.Histogram > 0; + bool bearishCross = macdPrev.MacdLine >= macdPrev.SignalLine && macdNow.MacdLine < macdNow.SignalLine && macdNow.Histogram < 0; + if (!bullishCross && !bearishCross) return null; + + decimal entry = current.Close; + SignalDirection direction = bullishCross ? SignalDirection.Buy : SignalDirection.Sell; + decimal stopLoss = direction == SignalDirection.Buy ? entry - (stopAtrMultiplier * atr) : entry + (stopAtrMultiplier * atr); + decimal risk = Math.Abs(entry - stopLoss); + if (risk <= 0) return null; + + decimal tp1 = direction == SignalDirection.Buy ? entry + (2.0m * risk) : entry - (2.0m * risk); + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.FixedSingleTarget, + InitialStopLoss: stopLoss, + TakeProfitStages: [new TakeProfitStage(1, tp1, 1.00m, 2.0m, "Target: 100% exit at +2.0R")], + MaxHoldingBars: 30 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: direction, + QualityScore: 80m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 2.0m, + ExitPlan: exitPlan, + TechnicalRationale: bullishCross + ? $"MACD line ({macdNow.MacdLine:F3}) crossed above the signal line ({macdNow.SignalLine:F3}) with a positive histogram." + : $"MACD line ({macdNow.MacdLine:F3}) crossed below the signal line ({macdNow.SignalLine:F3}) with a negative histogram.", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary + { + ["MACD_Line"] = macdNow.MacdLine, + ["MACD_Signal"] = macdNow.SignalLine, + ["MACD_Histogram"] = macdNow.Histogram, + ["ATR_14"] = atr + }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(5), + IsTopPick: false, + Rating: "B" + ); + } +} + +/// +/// 7. EMA50/EMA200 Golden Cross & Death Cross - the textbook long-horizon trend-change signal. +/// +public class MovingAverageCrossoverStrategy : ITechnicalStrategy +{ + public string StrategyKey => "MovingAverageCrossover"; + public string StrategyName => "EMA50/EMA200 Golden & Death Cross"; + public int Priority => 7; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + + int fastPeriod = (int)context.GetParameter(StrategyKey, "FastPeriod", 50m); + int slowPeriod = (int)context.GetParameter(StrategyKey, "SlowPeriod", 200m); + decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 2.0m); + + if (candles.Count < slowPeriod + 10) return null; + + var current = candles.Last(); + var previousCandles = candles.Take(candles.Count - 1).ToList(); + + decimal ema50Now = TechnicalIndicatorsEngine.CalculateEma(candles, fastPeriod); + decimal ema200Now = TechnicalIndicatorsEngine.CalculateEma(candles, slowPeriod); + decimal ema50Prev = TechnicalIndicatorsEngine.CalculateEma(previousCandles, fastPeriod); + decimal ema200Prev = TechnicalIndicatorsEngine.CalculateEma(previousCandles, slowPeriod); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + if (atr <= 0) return null; + + bool goldenCross = ema50Prev <= ema200Prev && ema50Now > ema200Now; + bool deathCross = ema50Prev >= ema200Prev && ema50Now < ema200Now; + if (!goldenCross && !deathCross) return null; + + decimal entry = current.Close; + SignalDirection direction = goldenCross ? SignalDirection.Buy : SignalDirection.Sell; + decimal stopLoss = direction == SignalDirection.Buy ? entry - (stopAtrMultiplier * atr) : entry + (stopAtrMultiplier * atr); + decimal risk = Math.Abs(entry - stopLoss); + if (risk <= 0) return null; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.PureTrailingStop, + InitialStopLoss: stopLoss, + TakeProfitStages: [], + TrailingStopRule: new TrailingStopRule( + Type: TrailingStopType.AtrMultiplier, + Multiplier: 2.5m, + ActivationPrice: entry, + IndicatorKey: "ATR_14" + ), + MaxHoldingBars: 100 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: direction, + QualityScore: 82m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 2.5m, + ExitPlan: exitPlan, + TechnicalRationale: goldenCross + ? $"Golden Cross: EMA50 ({ema50Now:F2}) crossed above EMA200 ({ema200Now:F2})." + : $"Death Cross: EMA50 ({ema50Now:F2}) crossed below EMA200 ({ema200Now:F2}).", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary { ["EMA_50"] = ema50Now, ["EMA_200"] = ema200Now, ["ATR_14"] = atr }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(24), + IsTopPick: true, + Rating: "A" + ); + } +} + +/// +/// 8. RSI Overbought/Oversold Threshold Cross - simple, direction-agnostic momentum-reversal strategy +/// (distinct from , which additionally requires Bollinger-band + ADX confluence). +/// +public class RsiReversalStrategy : ITechnicalStrategy +{ + public string StrategyKey => "RsiReversal"; + public string StrategyName => "RSI Overbought/Oversold Threshold Cross"; + public int Priority => 8; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + if (candles.Count < 30) return null; + + var current = candles.Last(); + var previousCandles = candles.Take(candles.Count - 1).ToList(); + if (previousCandles.Count < 15) return null; + + int rsiPeriod = (int)context.GetParameter(StrategyKey, "Period", 14m); + decimal oversoldThreshold = context.GetParameter(StrategyKey, "OversoldThreshold", 30m); + decimal overboughtThreshold = context.GetParameter(StrategyKey, "OverboughtThreshold", 70m); + decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.2m); + + decimal rsiNow = TechnicalIndicatorsEngine.CalculateRsi(candles, rsiPeriod); + decimal rsiPrev = TechnicalIndicatorsEngine.CalculateRsi(previousCandles, rsiPeriod); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + if (atr <= 0) return null; + + bool bullishCross = rsiPrev <= oversoldThreshold && rsiNow > oversoldThreshold; + bool bearishCross = rsiPrev >= overboughtThreshold && rsiNow < overboughtThreshold; + if (!bullishCross && !bearishCross) return null; + + decimal entry = current.Close; + SignalDirection direction = bullishCross ? SignalDirection.Buy : SignalDirection.Sell; + decimal stopLoss = direction == SignalDirection.Buy ? entry - (stopAtrMultiplier * atr) : entry + (stopAtrMultiplier * atr); + decimal risk = Math.Abs(entry - stopLoss); + if (risk <= 0) return null; + + decimal tp1 = direction == SignalDirection.Buy ? entry + (1.5m * risk) : entry - (1.5m * risk); + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.FixedSingleTarget, + InitialStopLoss: stopLoss, + TakeProfitStages: [new TakeProfitStage(1, tp1, 1.00m, 1.5m, "Target: 100% exit at +1.5R")], + MaxHoldingBars: 20 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: direction, + QualityScore: 75m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 1.5m, + ExitPlan: exitPlan, + TechnicalRationale: bullishCross + ? $"RSI ({rsiNow:F1}) crossed back above the oversold threshold of 30." + : $"RSI ({rsiNow:F1}) crossed back below the overbought threshold of 70.", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary { ["RSI_14"] = rsiNow, ["ATR_14"] = atr }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(3), + IsTopPick: false, + Rating: "B" + ); + } +} + +/// +/// 9. 20-Period Donchian Channel Breakout - the classic "Turtle Trading" breakout system. +/// +public class DonchianBreakoutStrategy : ITechnicalStrategy +{ + public string StrategyKey => "DonchianBreakout"; + public string StrategyName => "20-Period Donchian Channel Breakout"; + public int Priority => 9; + + public bool IsApplicable(MarketRegime regime) => true; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + int period = (int)context.GetParameter(StrategyKey, "Period", 20m); + var candles = context.PrimaryCandles; + if (candles.Count < period + 2) return null; + + var current = candles.Last(); + // Prior N bars, excluding the current bar itself - a breakout is a close beyond the range that had + // already formed BEFORE this bar, not beyond a range that includes the breakout bar itself. + var priorWindow = candles.Skip(candles.Count - 1 - period).Take(period).ToList(); + decimal highestHigh = priorWindow.Max(c => c.High); + decimal lowestLow = priorWindow.Min(c => c.Low); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + if (atr <= 0) return null; + + bool bullishBreakout = current.Close > highestHigh; + bool bearishBreakout = current.Close < lowestLow; + if (!bullishBreakout && !bearishBreakout) return null; + + decimal entry = current.Close; + SignalDirection direction = bullishBreakout ? SignalDirection.Buy : SignalDirection.Sell; + decimal stopLoss = direction == SignalDirection.Buy ? lowestLow : highestHigh; + decimal risk = Math.Abs(entry - stopLoss); + if (risk <= 0) return null; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.PureTrailingStop, + InitialStopLoss: stopLoss, + TakeProfitStages: [], + TrailingStopRule: new TrailingStopRule( + Type: TrailingStopType.AtrMultiplier, + Multiplier: 2.0m, + ActivationPrice: entry, + IndicatorKey: "ATR_14" + ), + MaxHoldingBars: 40 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: direction, + QualityScore: 83m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 2.0m, + ExitPlan: exitPlan, + TechnicalRationale: bullishBreakout + ? $"Breakout above the {period}-period high at {highestHigh:F2} (Donchian channel)." + : $"Breakdown below the {period}-period low at {lowestLow:F2} (Donchian channel).", + TriggeringPatterns: activePatterns.ToList(), + IndicatorSnapshot: new Dictionary { ["DonchianHigh"] = highestHigh, ["DonchianLow"] = lowestLow, ["ATR_14"] = atr }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(8), + IsTopPick: true, + Rating: "A" + ); + } +} + +/// +/// 10. VWAP Pullback & Bounce Confirmation - trades a retest of the session VWAP in the direction of the +/// prevailing short-term trend once price rejects back away from it. +/// +public class VwapBounceStrategy : ITechnicalStrategy +{ + public string StrategyKey => "VwapBounce"; + public string StrategyName => "VWAP Pullback & Bounce Confirmation"; + public int Priority => 10; + + public bool IsApplicable(MarketRegime regime) => + regime == MarketRegime.BullishTrending || regime == MarketRegime.BearishTrending; + + public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns) + { + var candles = context.PrimaryCandles; + if (candles.Count < 30) return null; + + int emaFastPeriod = (int)context.GetParameter(StrategyKey, "EmaFast", 20m); + int emaSlowPeriod = (int)context.GetParameter(StrategyKey, "EmaSlow", 50m); + decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.0m); + + var current = candles.Last(); + var previous = candles[^2]; + decimal vwap = TechnicalIndicatorsEngine.CalculateVwap(candles); + decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(candles, emaFastPeriod); + decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(candles, emaSlowPeriod); + decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14); + if (atr <= 0 || vwap <= 0) return null; + + // Bullish: uptrend, prior bar dipped to/below VWAP, current bar closed back above it (rejection/bounce). + bool bullishBounce = ema20 > ema50 && previous.Low <= vwap && current.Close > vwap; + // Bearish: downtrend, prior bar rallied to/above VWAP, current bar closed back below it (rejection). + bool bearishBounce = ema20 < ema50 && previous.High >= vwap && current.Close < vwap; + if (!bullishBounce && !bearishBounce) return null; + + decimal entry = current.Close; + SignalDirection direction = bullishBounce ? SignalDirection.Buy : SignalDirection.Sell; + decimal stopLoss = direction == SignalDirection.Buy + ? Math.Min(previous.Low, entry - (stopAtrMultiplier * atr)) + : Math.Max(previous.High, entry + (stopAtrMultiplier * atr)); + decimal risk = Math.Abs(entry - stopLoss); + if (risk <= 0) return null; + + decimal tp1 = direction == SignalDirection.Buy ? entry + (2.0m * risk) : entry - (2.0m * risk); + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.FixedSingleTarget, + InitialStopLoss: stopLoss, + TakeProfitStages: [new TakeProfitStage(1, tp1, 1.00m, 2.0m, "Target: 100% exit at +2.0R")], + MaxHoldingBars: 25 + ); + + return new StrategyResultDto( + SetupId: Guid.NewGuid(), + Isin: context.Isin, + Symbol: context.Symbol, + Timeframe: context.Timeframe, + StrategyKey: StrategyKey, + StrategyName: StrategyName, + Direction: direction, + QualityScore: 81m, + CurrentPrice: current.Close, + EntryPrice: entry, + InvalidationPrice: stopLoss, + CurrentAtr: atr, + EstimatedRiskRewardRatio: 2.0m, + ExitPlan: exitPlan, + TechnicalRationale: bullishBounce + ? $"Uptrend (EMA20>EMA50), pullback to VWAP ({vwap:F2}) with a bounce back above it." + : $"Downtrend (EMA20 { ["VWAP"] = vwap, ["EMA_20"] = ema20, ["EMA_50"] = ema50, ["ATR_14"] = atr }, + CreatedAt: current.Timestamp, + ExpiresAt: current.Timestamp.AddHours(4), + IsTopPick: false, + Rating: "B" + ); + } +} diff --git a/FinlyticTechnicals/Strategies/ITechnicalStrategy.cs b/FinlyticTechnicals/Strategies/ITechnicalStrategy.cs new file mode 100644 index 0000000..4878bff --- /dev/null +++ b/FinlyticTechnicals/Strategies/ITechnicalStrategy.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticTechnicals.Strategies; + +/// +/// Strategy contract for evaluating technical context, indicators, and detected patterns to produce structured trading setups with an ExitPlan. +/// +public interface ITechnicalStrategy +{ + string StrategyKey { get; } + string StrategyName { get; } + int Priority { get; } + + /// + /// Checks if this strategy is applicable in the current market regime. + /// + bool IsApplicable(MarketRegime regime); + + /// + /// Evaluates technical context and active patterns to generate a StrategyResultDto or null. + /// + StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList activePatterns); +} diff --git a/FinlyticTechnicals/Timeframe/CircularRingBuffer.cs b/FinlyticTechnicals/Timeframe/CircularRingBuffer.cs new file mode 100644 index 0000000..4bd806b --- /dev/null +++ b/FinlyticTechnicals/Timeframe/CircularRingBuffer.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; + +namespace FinlyticTechnicals.Timeframe; + +/// +/// Thread-safe, high-performance circular ring buffer with zero allocations on updates. +/// Holds a fixed capacity of items (e.g. 500 candles). +/// +/// The item type (e.g. CandleDto) +public class CircularRingBuffer : IReadOnlyList +{ + private readonly T[] _buffer; + private readonly int _capacity; + private int _start; + private int _count; + private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion); + + public CircularRingBuffer(int capacity = 500) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be positive."); + _capacity = capacity; + _buffer = new T[capacity]; + _start = 0; + _count = 0; + } + + public int Capacity => _capacity; + + public int Count + { + get + { + _lock.EnterReadLock(); + try { return _count; } + finally { _lock.ExitReadLock(); } + } + } + + /// + /// Adds an item to the buffer. If capacity is reached, the oldest element is overwritten in O(1). + /// + public void Add(T item) + { + _lock.EnterWriteLock(); + try + { + if (_count < _capacity) + { + int nextIndex = (_start + _count) % _capacity; + _buffer[nextIndex] = item; + _count++; + } + else + { + _buffer[_start] = item; + _start = (_start + 1) % _capacity; + } + } + finally + { + _lock.ExitWriteLock(); + } + } + + /// + /// Updates the last (most recent) item in place. + /// + public void UpdateLast(T item) + { + _lock.EnterWriteLock(); + try + { + if (_count == 0) + { + _buffer[_start] = item; + _count = 1; + } + else + { + int lastIndex = (_start + _count - 1) % _capacity; + _buffer[lastIndex] = item; + } + } + finally + { + _lock.ExitWriteLock(); + } + } + + /// + /// Gets the most recent item or default if empty. + /// + public T? GetLast() + { + _lock.EnterReadLock(); + try + { + if (_count == 0) return default; + int lastIndex = (_start + _count - 1) % _capacity; + return _buffer[lastIndex]; + } + finally + { + _lock.ExitReadLock(); + } + } + + /// + /// Indexer accessing items from oldest (0) to newest (Count - 1). + /// + public T this[int index] + { + get + { + _lock.EnterReadLock(); + try + { + if (index < 0 || index >= _count) + throw new ArgumentOutOfRangeException(nameof(index), "Index out of range."); + + int actualIndex = (_start + index) % _capacity; + return _buffer[actualIndex]; + } + finally + { + _lock.ExitReadLock(); + } + } + } + + /// + /// Returns an ordered immutable array snapshot of all elements. + /// + public T[] ToArray() + { + _lock.EnterReadLock(); + try + { + if (_count == 0) return Array.Empty(); + var result = new T[_count]; + for (int i = 0; i < _count; i++) + { + int actualIndex = (_start + i) % _capacity; + result[i] = _buffer[actualIndex]; + } + return result; + } + finally + { + _lock.ExitReadLock(); + } + } + + /// + /// Populates the buffer in bulk with historical data (oldest to newest). + /// + public void LoadBulk(IEnumerable items) + { + _lock.EnterWriteLock(); + try + { + _start = 0; + _count = 0; + foreach (var item in items) + { + if (_count < _capacity) + { + _buffer[_count] = item; + _count++; + } + else + { + _buffer[_start] = item; + _start = (_start + 1) % _capacity; + } + } + } + finally + { + _lock.ExitWriteLock(); + } + } + + public Enumerator GetEnumerator() => new(this); + IEnumerator IEnumerable.GetEnumerator() => new Enumerator(this); + IEnumerator IEnumerable.GetEnumerator() => new Enumerator(this); + + public struct Enumerator : IEnumerator + { + private readonly CircularRingBuffer _buffer; + private int _index; + private T? _current; + + internal Enumerator(CircularRingBuffer buffer) + { + _buffer = buffer; + _index = 0; + _current = default; + } + + public readonly T Current => _current!; + readonly object? IEnumerator.Current => Current; + + public bool MoveNext() + { + if (_index < _buffer.Count) + { + _current = _buffer[_index]; + _index++; + return true; + } + _current = default; + return false; + } + + public void Reset() + { + _index = 0; + _current = default; + } + + public readonly void Dispose() { } + } +} + diff --git a/FinlyticTechnicals/Util/SettingKeys.cs b/FinlyticTechnicals/Util/SettingKeys.cs new file mode 100644 index 0000000..71c8dd3 --- /dev/null +++ b/FinlyticTechnicals/Util/SettingKeys.cs @@ -0,0 +1,26 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticTechnicals.Util; + +public static class SettingKeys +{ + // --- Logging-Kanäle --- + public static readonly SettingKey TechnicalAnalysisChannel = new("Logging.Channel.TechnicalAnalysis", true); + public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); + public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); + + // --- Indikator-Konfiguration --- + public static readonly SettingKey RsiPeriod = new("Indicators.RsiPeriod", 14); + public static readonly SettingKey MacdFastPeriod = new("Indicators.MacdFastPeriod", 12); + public static readonly SettingKey MacdSlowPeriod = new("Indicators.MacdSlowPeriod", 26); + public static readonly SettingKey MacdSignalPeriod = new("Indicators.MacdSignalPeriod", 9); + public static readonly SettingKey EmaShortPeriod = new("Indicators.EmaShortPeriod", 50); + public static readonly SettingKey EmaLongPeriod = new("Indicators.EmaLongPeriod", 200); + public static readonly SettingKey BollingerBandsPeriod = new("Indicators.BollingerBandsPeriod", 20); + public static readonly SettingKey BollingerBandsStdDev = new("Indicators.BollingerBandsStdDev", 2.0); + public static readonly SettingKey AtrPeriod = new("Indicators.AtrPeriod", 14); + + // --- Cache & Performance --- + public static readonly SettingKey CacheDurationMinutes = new("Cache.DurationMinutes", 60); + public static readonly SettingKey EnableAutoCache = new("Feature.EnableAutoCache", true); +} diff --git a/FinlyticTechnicals/Util/TAMqttClient.cs b/FinlyticTechnicals/Util/TAMqttClient.cs new file mode 100644 index 0000000..73ee303 --- /dev/null +++ b/FinlyticTechnicals/Util/TAMqttClient.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Settings; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Models; +using FinlyticCore.Services; +using FinlyticCore.Util; +using FinlyticTechnicals.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticTechnicals.Util; + +public record GetSetupsRequest( + bool TopPicksOnly = false, + int Limit = 50, + decimal? MinScore = null +); + +public record GetCandlesRequest( + string Isin = "", + string Timeframe = "15m" +); + +public class TAMqttClient : ManagedMqttClient, IHostedService, ITAMqttRpcClient +{ + private readonly IConfiguration _configuration; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public TAMqttClient( + ILogger logger, + IConfiguration configuration, + IServiceScopeFactory scopeFactory) : base(logger) + { + _logger = logger; + _configuration = configuration; + _scopeFactory = scopeFactory; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticTechnicals"); + + _logger.LogInformation("Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); + await ConnectAsync(config); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping Technical Analysis MQTT client."); + await DisconnectAsync(); + } + + protected override async Task OnConnectedAsync() + { + _logger.LogInformation("Technical Analysis MQTT client connected. Registering RPC endpoints..."); + + await SubscribeAsync(MqttTopics.ResponseWildcard); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetAnalysis), HandleGetAnalysisRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetupsForIsin), HandleGetSetupsForIsinRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetSetups), HandleGetSetupsRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetCandles), HandleGetCandlesRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetWatchlist), HandleGetWatchlistRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaGetRecentSetupHistory), HandleGetRecentSetupHistoryRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsGetAll), HandleSettingsGetAllRpcAsync); + await SubscribeRpcAsync, List>(MqttTopics.RequestFilter(MqttTopics.Channels.TaSettingsUpdate), HandleSettingsUpdateRpcAsync); + await SubscribeAsync(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync); + + // Subscribe to Sentiment Spikes & Stream + await SubscribeAsync(MqttTopics.SentimentWildcard); + + FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && (string.Equals(logDto.ServiceName, "FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || string.Equals(logDto.ServiceName, "FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase))) + { + await PublishAsync(MqttTopics.Logs("FinlyticTechnicals"), logDto); + } + }; + } + + protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) + { + if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return; + + try + { + if (topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase)) + { + await HandleSentimentEventAsync(topic, payloadStr); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[TAMqttClient] Error handling message on topic {Topic}", topic); + } + } + + private async Task HandleSentimentEventAsync(string topic, string payloadStr) + { + using var jsonDoc = JsonDocument.Parse(payloadStr); + var root = jsonDoc.RootElement; + + string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null; + if (string.IsNullOrWhiteSpace(isin)) + { + // Try extracting from topic if stream format: finlytic/sentiment/stream/{isin} + var parts = topic.Split('/'); + if (parts.Length >= 4 && parts[2].Equals("stream", StringComparison.OrdinalIgnoreCase)) + { + isin = parts[3]; + } + } + + if (string.IsNullOrWhiteSpace(isin)) return; + + double compoundScore = 0.0; + string? trend = null; + + if (root.TryGetProperty("currentSummary", out var currSummary)) + { + if (currSummary.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble(); + if (currSummary.TryGetProperty("trend", out var trProp)) trend = trProp.GetString(); + } + else + { + if (root.TryGetProperty("compoundScore", out var csProp)) compoundScore = csProp.GetDouble(); + if (root.TryGetProperty("trend", out var trProp)) trend = trProp.GetString(); + } + + bool isSpike = Math.Abs(compoundScore) >= 0.5 || + string.Equals(trend, "IMPROVING", StringComparison.OrdinalIgnoreCase) || + string.Equals(trend, "DETERIORATING", StringComparison.OrdinalIgnoreCase); + + if (isSpike) + { + using var scope = _scopeFactory.CreateScope(); + var universeManager = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + await universeManager.AddOrUpdateAssetAsync(isin, null, UniverseSource.SentimentSpike, priority: 1, ttl: TimeSpan.FromMinutes(120)); + + await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, + "[TAMqttClient] Sentiment Spike event detected on {Topic} for ISIN {Isin} (Compound={Score:F2}, Trend={Trend}) -> Promoted to Priority 1 (TTL 120m)", + topic, isin, compoundScore, trend ?? "N/A"); + } + } + + + private async Task HandleGetAnalysisRpcAsync(IsinRequest? req, string correlationId) + { + if (string.IsNullOrWhiteSpace(req?.Isin)) return null; + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var scoringEngine = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetAnalysis for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId); + return await scoringEngine.GetTechnicalAnalysisDtoAsync(req.Isin, req.Ticker); + } + + private async Task> HandleGetSetupsForIsinRpcAsync(IsinRequest? req, string correlationId) + { + if (string.IsNullOrWhiteSpace(req?.Isin)) return []; + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var scoringEngine = scope.ServiceProvider.GetRequiredService(); + var universeManager = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetupsForIsin for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId); + + // Attach the current universe-selection reason (if the ISIN is actively monitored) so a caller (e.g. + // FinlyticEngine's TradeLifecycleService) can record WHY this asset was being watched, not just its + // scores. Stays null for an ISIN nobody favorited/discovered/spiked - an honest ad hoc analysis. + var universeEntry = await universeManager.GetEntryAsync(req.Isin); + return await scoringEngine.AnalyzeIsinAsync(req.Isin, req.Ticker, universeEntry?.Source, universeEntry?.AddedAtUtc); + } + + private async Task> HandleGetSetupsRpcAsync(GetSetupsRequest? req, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var scoringEngine = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetSetups (TopPicks: {TopPicks}, Limit: {Limit}, MinScore: {MinScore}) [CorrelationId: {CorrelationId}]", req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore?.ToString() ?? "null", correlationId); + return await scoringEngine.GetActiveSetupsAsync(req?.TopPicksOnly ?? false, req?.Limit ?? 50, req?.MinScore); + + } + + private async Task> HandleGetWatchlistRpcAsync(object? req, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var universeManager = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetWatchlist [CorrelationId: {CorrelationId}]", correlationId); + + var universe = await universeManager.GetActiveUniverseAsync(); + return universe.Select(e => new WatchlistEntryDto(e.Isin, e.Symbol, e.Source.ToString(), e.Priority, e.AddedAtUtc, e.ExpiresAtUtc)).ToList(); + } + + private async Task> HandleGetRecentSetupHistoryRpcAsync(GetRecentSetupHistoryRequest? req, string correlationId) + { + if (string.IsNullOrWhiteSpace(req?.Isin)) return []; + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var scoringEngine = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TAMqttClient] Processing RPC ta_GetRecentSetupHistory for ISIN {Isin} [CorrelationId: {CorrelationId}]", req.Isin, correlationId); + + return await scoringEngine.GetRecentSetupHistoryAsync(req.Isin, req.Limit); + } + + private Task> HandleGetCandlesRpcAsync(GetCandlesRequest? req, string correlationId) + { + if (string.IsNullOrWhiteSpace(req?.Isin)) return Task.FromResult>([]); + + using var scope = _scopeFactory.CreateScope(); + var aggregator = scope.ServiceProvider.GetRequiredService(); + + var candles = aggregator.GetCandles(req.Isin, req.Timeframe ?? "15m"); + return Task.FromResult(candles); + } + + private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_GetAll] Retrieving dynamic settings [CorrelationId: {CorrelationId}]", correlationId); + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); + } + + private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] [Settings_Update] Processing settings update [CorrelationId: {CorrelationId}]", correlationId); + if (updates != null && updates.Count > 0) + { + await settingsService.UpdateSettingsAsync(updates); + await logger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicals] Successfully updated {Count} settings in database and cache.", updates.Count); + } + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); + } + + private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId) + { + if (topic.Contains("FinlyticTechnicals", StringComparison.OrdinalIgnoreCase) || topic.Contains("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticTechnicals", "Online", DateTime.UtcNow, "Connected")); + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + await logger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticTechnicals] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId); + } + } +} \ No newline at end of file diff --git a/FinlyticTechnicals/appsettings.json b/FinlyticTechnicals/appsettings.json new file mode 100644 index 0000000..7c2685a --- /dev/null +++ b/FinlyticTechnicals/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "FinlyticCore.Services.TradeRepublic.TradeRepublicClient": "Debug" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_ta;Username=admin;Password=admin" + }, + "MQTT": { + "Host": "localhost", + "Port": "4545", + "ClientId": "finlytic_ta" + } +}