diff --git a/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs b/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs new file mode 100644 index 0000000..1c6f7de --- /dev/null +++ b/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs @@ -0,0 +1,28 @@ +using FinlyticTechnicalAnalysis.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticTechnicalAnalysis.Database; + +public class TechnicalAnalysisDbContext : DbContext +{ + public TechnicalAnalysisDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet MarketCandles => Set(); + public DbSet MacroData => Set(); + public DbSet CachedAnalyses => Set(); + public DbSet Settings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasIndex(c => new { c.Symbol, c.Interval, c.Timestamp }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(c => c.Isin); + } +} diff --git a/FinlyticTechnicalAnalysis/Dockerfile b/FinlyticTechnicalAnalysis/Dockerfile new file mode 100644 index 0000000..5e82bc0 --- /dev/null +++ b/FinlyticTechnicalAnalysis/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 ["FinlyticTechnicalAnalysis/FinlyticTechnicalAnalysis.csproj", "FinlyticTechnicalAnalysis/"] +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +RUN dotnet restore "FinlyticTechnicalAnalysis/FinlyticTechnicalAnalysis.csproj" +COPY . . +WORKDIR "/src/FinlyticTechnicalAnalysis" +RUN dotnet build "./FinlyticTechnicalAnalysis.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FinlyticTechnicalAnalysis.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticTechnicalAnalysis.dll"] diff --git a/FinlyticTechnicalAnalysis/Entities/CachedAnalysisEntity.cs b/FinlyticTechnicalAnalysis/Entities/CachedAnalysisEntity.cs new file mode 100644 index 0000000..4745ff7 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Entities/CachedAnalysisEntity.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticTechnicalAnalysis.Entities; + +[Table("CachedAnalyses")] +public class CachedAnalysisEntity +{ + [Key] + [MaxLength(20)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(20)] + public string Ticker { get; set; } = string.Empty; + + [Column(TypeName = "jsonb")] + public string AnalysisJson { get; set; } = "{}"; + + public DateTime CalculatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticTechnicalAnalysis/Entities/MacroDataEntity.cs b/FinlyticTechnicalAnalysis/Entities/MacroDataEntity.cs new file mode 100644 index 0000000..3d8809e --- /dev/null +++ b/FinlyticTechnicalAnalysis/Entities/MacroDataEntity.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticTechnicalAnalysis.Entities; + +[Table("MacroData")] +public class MacroDataEntity +{ + [Key] + [MaxLength(20)] + public string Symbol { get; set; } = string.Empty; // "^VIX", "^GSPC", "DX-Y.NY" + + [Column(TypeName = "decimal(18, 6)")] + public decimal Value { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal PreviousClose { get; set; } + + [MaxLength(50)] + public string TrendState { get; set; } = "Neutral"; + + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticTechnicalAnalysis/Entities/MarketCandleEntity.cs b/FinlyticTechnicalAnalysis/Entities/MarketCandleEntity.cs new file mode 100644 index 0000000..c69168a --- /dev/null +++ b/FinlyticTechnicalAnalysis/Entities/MarketCandleEntity.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticTechnicalAnalysis.Entities; + +[Table("MarketCandles")] +public class MarketCandleEntity +{ + [Key] + public long Id { get; set; } + + [Required] + [MaxLength(20)] + public string Symbol { get; set; } = string.Empty; // e.g. "US5398301094" or "AAPL" or "^VIX" + + [Required] + [MaxLength(10)] + public string Interval { get; set; } = "1d"; // "1h", "1d" + + [Required] + public DateTime Timestamp { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal Open { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal High { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal Low { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal Close { get; set; } + + public long Volume { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal? Bid { get; set; } + + [Column(TypeName = "decimal(18, 6)")] + public decimal? Ask { get; set; } +} diff --git a/FinlyticTechnicalAnalysis/Entities/TaSettingsEntity.cs b/FinlyticTechnicalAnalysis/Entities/TaSettingsEntity.cs new file mode 100644 index 0000000..25713a2 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Entities/TaSettingsEntity.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace FinlyticTechnicalAnalysis.Entities; + +/// +/// Entity representing global indicator and strategy settings for FinlyticTechnicalAnalysis. +/// Persisted in PostgreSQL and updated dynamically via Admin Panel MQTT events. +/// +public class TaSettingsEntity +{ + [Key] + public Guid Id { get; set; } + + public int EmaShortPeriod { get; set; } = 20; + public int SmaMediumPeriod { get; set; } = 50; + public int SmaLongPeriod { get; set; } = 200; + public double RsiOverboughtLimit { get; set; } = 70.0; + public double RsiOversoldLimit { get; set; } = 30.0; + public double SupertrendMultiplier { get; set; } = 3.0; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticTechnicalAnalysis/FinlyticTechnicalAnalysis.csproj b/FinlyticTechnicalAnalysis/FinlyticTechnicalAnalysis.csproj new file mode 100644 index 0000000..4c7f8d9 --- /dev/null +++ b/FinlyticTechnicalAnalysis/FinlyticTechnicalAnalysis.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/FinlyticTechnicalAnalysis/Migrations/20260801073352_Init.Designer.cs b/FinlyticTechnicalAnalysis/Migrations/20260801073352_Init.Designer.cs new file mode 100644 index 0000000..7d129c4 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Migrations/20260801073352_Init.Designer.cs @@ -0,0 +1,162 @@ +// +using System; +using FinlyticTechnicalAnalysis.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 FinlyticTechnicalAnalysis.Migrations +{ + [DbContext(typeof(TechnicalAnalysisDbContext))] + [Migration("20260801073352_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("FinlyticTechnicalAnalysis.Entities.CachedAnalysisEntity", b => + { + b.Property("Isin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AnalysisJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Isin"); + + b.HasIndex("Isin"); + + b.ToTable("CachedAnalyses"); + }); + + modelBuilder.Entity("FinlyticTechnicalAnalysis.Entities.MacroDataEntity", b => + { + b.Property("Symbol") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousClose") + .HasColumnType("decimal(18, 6)"); + + b.Property("TrendState") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .HasColumnType("decimal(18, 6)"); + + b.HasKey("Symbol"); + + b.ToTable("MacroData"); + }); + + modelBuilder.Entity("FinlyticTechnicalAnalysis.Entities.MarketCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18, 6)"); + + b.Property("Bid") + .HasColumnType("decimal(18, 6)"); + + b.Property("Close") + .HasColumnType("decimal(18, 6)"); + + b.Property("High") + .HasColumnType("decimal(18, 6)"); + + b.Property("Interval") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Low") + .HasColumnType("decimal(18, 6)"); + + b.Property("Open") + .HasColumnType("decimal(18, 6)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Symbol", "Interval", "Timestamp") + .IsUnique(); + + b.ToTable("MarketCandles"); + }); + + modelBuilder.Entity("FinlyticTechnicalAnalysis.Entities.TaSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EmaShortPeriod") + .HasColumnType("integer"); + + b.Property("RsiOverboughtLimit") + .HasColumnType("double precision"); + + b.Property("RsiOversoldLimit") + .HasColumnType("double precision"); + + b.Property("SmaLongPeriod") + .HasColumnType("integer"); + + b.Property("SmaMediumPeriod") + .HasColumnType("integer"); + + b.Property("SupertrendMultiplier") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicalAnalysis/Migrations/20260801073352_Init.cs b/FinlyticTechnicalAnalysis/Migrations/20260801073352_Init.cs new file mode 100644 index 0000000..228583d --- /dev/null +++ b/FinlyticTechnicalAnalysis/Migrations/20260801073352_Init.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicalAnalysis.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CachedAnalyses", + columns: table => new + { + Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Ticker = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + AnalysisJson = table.Column(type: "jsonb", nullable: false), + CalculatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CachedAnalyses", x => x.Isin); + }); + + migrationBuilder.CreateTable( + name: "MacroData", + columns: table => new + { + Symbol = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Value = table.Column(type: "numeric(18,6)", nullable: false), + PreviousClose = table.Column(type: "numeric(18,6)", nullable: false), + TrendState = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + LastUpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MacroData", x => x.Symbol); + }); + + migrationBuilder.CreateTable( + name: "MarketCandles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Symbol = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Interval = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + Open = table.Column(type: "numeric(18,6)", nullable: false), + High = table.Column(type: "numeric(18,6)", nullable: false), + Low = table.Column(type: "numeric(18,6)", nullable: false), + Close = table.Column(type: "numeric(18,6)", nullable: false), + Volume = table.Column(type: "bigint", nullable: false), + Bid = table.Column(type: "numeric(18,6)", nullable: true), + Ask = table.Column(type: "numeric(18,6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MarketCandles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + EmaShortPeriod = table.Column(type: "integer", nullable: false), + SmaMediumPeriod = table.Column(type: "integer", nullable: false), + SmaLongPeriod = table.Column(type: "integer", nullable: false), + RsiOverboughtLimit = table.Column(type: "double precision", nullable: false), + RsiOversoldLimit = table.Column(type: "double precision", nullable: false), + SupertrendMultiplier = table.Column(type: "double precision", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_CachedAnalyses_Isin", + table: "CachedAnalyses", + column: "Isin"); + + migrationBuilder.CreateIndex( + name: "IX_MarketCandles_Symbol_Interval_Timestamp", + table: "MarketCandles", + columns: new[] { "Symbol", "Interval", "Timestamp" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CachedAnalyses"); + + migrationBuilder.DropTable( + name: "MacroData"); + + migrationBuilder.DropTable( + name: "MarketCandles"); + + migrationBuilder.DropTable( + name: "Settings"); + } + } +} diff --git a/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs b/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs new file mode 100644 index 0000000..a1a97b8 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs @@ -0,0 +1,159 @@ +// +using System; +using FinlyticTechnicalAnalysis.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTechnicalAnalysis.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("FinlyticTechnicalAnalysis.Entities.CachedAnalysisEntity", b => + { + b.Property("Isin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AnalysisJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Isin"); + + b.HasIndex("Isin"); + + b.ToTable("CachedAnalyses"); + }); + + modelBuilder.Entity("FinlyticTechnicalAnalysis.Entities.MacroDataEntity", b => + { + b.Property("Symbol") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousClose") + .HasColumnType("decimal(18, 6)"); + + b.Property("TrendState") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .HasColumnType("decimal(18, 6)"); + + b.HasKey("Symbol"); + + b.ToTable("MacroData"); + }); + + modelBuilder.Entity("FinlyticTechnicalAnalysis.Entities.MarketCandleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Ask") + .HasColumnType("decimal(18, 6)"); + + b.Property("Bid") + .HasColumnType("decimal(18, 6)"); + + b.Property("Close") + .HasColumnType("decimal(18, 6)"); + + b.Property("High") + .HasColumnType("decimal(18, 6)"); + + b.Property("Interval") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Low") + .HasColumnType("decimal(18, 6)"); + + b.Property("Open") + .HasColumnType("decimal(18, 6)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Symbol", "Interval", "Timestamp") + .IsUnique(); + + b.ToTable("MarketCandles"); + }); + + modelBuilder.Entity("FinlyticTechnicalAnalysis.Entities.TaSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EmaShortPeriod") + .HasColumnType("integer"); + + b.Property("RsiOverboughtLimit") + .HasColumnType("double precision"); + + b.Property("RsiOversoldLimit") + .HasColumnType("double precision"); + + b.Property("SmaLongPeriod") + .HasColumnType("integer"); + + b.Property("SmaMediumPeriod") + .HasColumnType("integer"); + + b.Property("SupertrendMultiplier") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTechnicalAnalysis/Program.cs b/FinlyticTechnicalAnalysis/Program.cs new file mode 100644 index 0000000..fa55554 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Program.cs @@ -0,0 +1,59 @@ +using System; +using FinlyticCore.Services.TradeRepublic; +using FinlyticTechnicalAnalysis.Database; +using FinlyticTechnicalAnalysis.Services; +using FinlyticTechnicalAnalysis.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Register DB Context +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Register HTTP Clients +builder.Services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + UseCookies = true, + CookieContainer = new System.Net.CookieContainer() + }); + +// Register Trade Republic WebSocket Client & Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register Technical Analysis Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// Register MQTT Client (as a Hosted Service) +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Run startup database migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + Console.WriteLine("Database migrations successfully executed for FinlyticTechnicalAnalysis."); + + var settingsService = scope.ServiceProvider.GetRequiredService(); + await settingsService.GetSettingsAsync(); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogError(ex, "[{Channel}] An error occurred during database migration on startup.", "TechnicalAnalysisChannel"); + } +} + +await host.RunAsync(); diff --git a/FinlyticTechnicalAnalysis/Project.md b/FinlyticTechnicalAnalysis/Project.md new file mode 100644 index 0000000..78b5346 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Project.md @@ -0,0 +1,34 @@ +# Finlytic Technical Analysis Service + +Finlytic Technical Analysis is a C# microservice providing real-time technical indicator calculations, candle pattern recognition, and trend regime evaluations for traded assets. + +--- + +## Core Features & Architecture + +1. **Indicator Calculations**: + - Calculates Exponential Moving Averages (`EMA 20`), Simple Moving Averages (`SMA 50`, `SMA 200`), Relative Strength Index (`RSI 14`), Moving Average Convergence Divergence (`MACD`), and `Supertrend`. + +2. **Chart Pattern Detection**: + - Detects technical chart patterns (`ChartPatternDto`) including Double Bottoms, Head & Shoulders, Bull Flags, and Trendline breakouts. + +3. **Macro Market Regime Mapping**: + - Evaluates overall technical signals (`BUY`, `STRONG BUY`, `NEUTRAL`, `SELL`, `STRONG SELL`). + +4. **MQTT RPC & Event Messaging**: + - Publishes technical analysis updates to `finlytic/technicalanalysis/{symbol}` and `finlytic/ta/{symbol}`. + - Answers RPC queries on `services/request/ta_GetAnalysis/#`. + +--- + +## Feature Status + +### Implemented Features +- [x] Technical Indicator Calculations (`IndicatorValuesDto`, `TechnicalAnalysisDto`). +- [x] Chart Pattern Detection Service (`IChartPatternDetector`). +- [x] Zero-Allocation MQTT serialization via `FinlyticJsonSerializerContext`. +- [x] Pure Worker Service architecture (no Kestrel HTTP webserver). + +### Planned Features +- [ ] Auto-tuned indicator parameters based on asset volatility regime (Adaptive EMA/RSI). +- [ ] Multi-timeframe indicator alignment matrix (5m, 1h, 1D, 1W sync). diff --git a/FinlyticTechnicalAnalysis/Services/SettingsDbService.cs b/FinlyticTechnicalAnalysis/Services/SettingsDbService.cs new file mode 100644 index 0000000..c55cad4 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Services/SettingsDbService.cs @@ -0,0 +1,100 @@ +using FinlyticTechnicalAnalysis.Database; +using FinlyticTechnicalAnalysis.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticTechnicalAnalysis.Services; + +public interface ISettingsDbService +{ + /// + /// Gets the settings. + /// + Task GetSettingsAsync(); + /// + /// Saves the settings. + /// + Task SaveSettingsAsync(TaSettingsEntity settings); + /// + /// Updates settings from a dictionary. + /// + Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary); +} + +public class SettingsDbService : ISettingsDbService +{ + private readonly TechnicalAnalysisDbContext _context; + + public SettingsDbService(TechnicalAnalysisDbContext context) + { + _context = context; + } + + /// + /// Gets the settings. + /// + public async Task GetSettingsAsync() + { + var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync(); + if (settings == null) + { + settings = new TaSettingsEntity { Id = Guid.NewGuid() }; + _context.Settings.Add(settings); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + } + return settings; + } + + /// + /// Saves the settings. + /// + public async Task SaveSettingsAsync(TaSettingsEntity settings) + { + var existing = await _context.Settings.FirstOrDefaultAsync(); + if (existing == null) + { + if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid(); + _context.Settings.Add(settings); + } + else + { + existing.EmaShortPeriod = settings.EmaShortPeriod; + existing.SmaMediumPeriod = settings.SmaMediumPeriod; + existing.SmaLongPeriod = settings.SmaLongPeriod; + existing.RsiOverboughtLimit = settings.RsiOverboughtLimit; + existing.RsiOversoldLimit = settings.RsiOversoldLimit; + existing.SupertrendMultiplier = settings.SupertrendMultiplier; + existing.UpdatedAt = settings.UpdatedAt; + _context.Settings.Update(existing); + } + await _context.SaveChangesAsync(); + return settings; + } + + /// + /// Updates settings from a dictionary. + /// + public async Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary) + { + var settings = await GetSettingsAsync(); + + foreach (var (key, value) in dictionary) + { + if (string.Equals(key, "EmaShortPeriod", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var esp)) + settings.EmaShortPeriod = esp; + else if (string.Equals(key, "SmaMediumPeriod", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var smp)) + settings.SmaMediumPeriod = smp; + else if (string.Equals(key, "SmaLongPeriod", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var slp)) + settings.SmaLongPeriod = slp; + else if (string.Equals(key, "RsiOverboughtLimit", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var rsiOb)) + settings.RsiOverboughtLimit = rsiOb; + else if (string.Equals(key, "RsiOversoldLimit", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var rsiOs)) + settings.RsiOversoldLimit = rsiOs; + else if (string.Equals(key, "SupertrendMultiplier", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var stm)) + settings.SupertrendMultiplier = stm; + } + + settings.UpdatedAt = DateTime.UtcNow; + await SaveSettingsAsync(settings); + } +} diff --git a/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisCalculator.cs b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisCalculator.cs new file mode 100644 index 0000000..b10e544 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisCalculator.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticTechnicalAnalysis.Entities; +using Skender.Stock.Indicators; + +namespace FinlyticTechnicalAnalysis.Services; + +public interface ITechnicalAnalysisCalculator +{ + /// + /// Calculates the technical analysis using Skender.StockIndicators for math and custom algorithms for pattern detection. + /// + (List Indicators, List Patterns, List Signals) CalculateAnalysis(List candles, string currency = "EUR"); +} + +public class TechnicalAnalysisCalculator : ITechnicalAnalysisCalculator +{ + public (List Indicators, List Patterns, List Signals) CalculateAnalysis(List candles, string currency = "EUR") + { + var indicators = new List(); + var patterns = new List(); + var signals = new List(); + + if (candles == null || candles.Count == 0) + return (indicators, patterns, signals); + + var curSym = GetCurrencySymbol(currency); + var sortedCandles = candles.OrderBy(c => c.Timestamp).ToList(); + + // 1. Convert domain candles to Skender Quotes + var quotes = sortedCandles.Select(c => new Quote + { + Date = c.Timestamp, + Open = c.Open, + High = c.High, + Low = c.Low, + Close = c.Close, + Volume = c.Volume + }).ToList(); + + // 2. Compute Indicators via Skender.StockIndicators + var ema20List = quotes.GetEma(20).ToList(); + var sma50List = quotes.GetSma(50).ToList(); + var sma200List = quotes.GetSma(200).ToList(); + var rsi14List = quotes.GetRsi(14).ToList(); + var macdList = quotes.GetMacd(12, 26, 9).ToList(); + var atr14List = quotes.GetAtr(14).ToList(); + var vwapList = quotes.GetVwap().ToList(); + var supertrendList = quotes.GetSuperTrend(10, 3.0).ToList(); + + // Build IndicatorValuesDto list per candle + for (int i = 0; i < sortedCandles.Count; i++) + { + var candle = sortedCandles[i]; + var closeVal = candle.Close; + + var atr = atr14List[i].Atr.HasValue ? (decimal)atr14List[i].Atr!.Value : 0m; + var stopLoss = atr > 0m ? closeVal - (1.5m * atr) : (decimal?)null; + + // Map Supertrend direction string + string? superDir = null; + if (supertrendList[i].LowerBand.HasValue) superDir = "Bullish"; + else if (supertrendList[i].UpperBand.HasValue) superDir = "Bearish"; + + indicators.Add(new IndicatorValuesDto( + Timestamp: candle.Timestamp, + Ema20: ema20List[i].Ema.HasValue ? (decimal)ema20List[i].Ema!.Value : null, + Sma50: sma50List[i].Sma.HasValue ? (decimal)sma50List[i].Sma!.Value : null, + Sma200: sma200List[i].Sma.HasValue ? (decimal)sma200List[i].Sma!.Value : null, + Rsi14: rsi14List[i].Rsi.HasValue ? (decimal)rsi14List[i].Rsi!.Value : null, + MacdLine: macdList[i].Macd.HasValue ? (decimal)macdList[i].Macd!.Value : null, + MacdSignal: macdList[i].Signal.HasValue ? (decimal)macdList[i].Signal!.Value : null, + MacdHistogram: macdList[i].Histogram.HasValue ? (decimal)macdList[i].Histogram!.Value : null, + Atr14: atr > 0m ? atr : null, + Vwap: vwapList[i].Vwap.HasValue ? (decimal)vwapList[i].Vwap!.Value : null, + SupertrendUpper: supertrendList[i].UpperBand.HasValue ? (decimal)supertrendList[i].UpperBand!.Value : null, + SupertrendLower: supertrendList[i].LowerBand.HasValue ? (decimal)supertrendList[i].LowerBand!.Value : null, + SupertrendDirection: superDir, + RecommendedStopLoss: stopLoss + )); + } + + // 3. Detect Strategy Signals using computed indicator lists + var sma50Values = sma50List.Select(x => x.Sma).ToList(); + var sma200Values = sma200List.Select(x => x.Sma).ToList(); + var rsiValues = rsi14List.Select(x => x.Rsi).ToList(); + + DetectStrategySignals(sortedCandles, sma50Values, sma200Values, rsiValues, signals); + + // 4. Detect Geometric Chart Patterns + DetectTrianglePatterns(sortedCandles, patterns, curSym); + + return (indicators, patterns, signals); + } + + private static string GetCurrencySymbol(string currency) + { + if (string.IsNullOrWhiteSpace(currency)) return "€"; + return currency.ToUpperInvariant() switch + { + "USD" => "$", + "GBP" => "£", + "CHF" => "CHF ", + "JPY" => "¥", + _ => "€" + }; + } + + private static void DetectStrategySignals(List candles, List sma50, List sma200, List rsi14, List signals) + { + for (int i = 1; i < candles.Count; i++) + { + var candle = candles[i]; + + // Golden Cross / Death Cross + if (sma50[i - 1].HasValue && sma200[i - 1].HasValue && sma50[i].HasValue && sma200[i].HasValue) + { + if (sma50[i - 1]!.Value <= sma200[i - 1]!.Value && sma50[i]!.Value > sma200[i]!.Value) + { + signals.Add(new StrategySignalDto( + Type: "GoldenCross", + Timestamp: candle.Timestamp, + Direction: "BUY", + Price: candle.Close, + Description: "Golden Cross: SMA 50 hat den SMA 200 von unten nach oben gekreuzt (Bullisches Signal)." + )); + } + else if (sma50[i - 1]!.Value >= sma200[i - 1]!.Value && sma50[i]!.Value < sma200[i]!.Value) + { + signals.Add(new StrategySignalDto( + Type: "DeathCross", + Timestamp: candle.Timestamp, + Direction: "SELL", + Price: candle.Close, + Description: "Death Cross: SMA 50 hat den SMA 200 von oben nach unten gekreuzt (Bearisches Signal)." + )); + } + } + + // RSI Oversold / Overbought Rebounds + if (rsi14[i].HasValue && rsi14[i - 1].HasValue) + { + if (rsi14[i - 1]!.Value < 30 && rsi14[i]!.Value >= 30) + { + signals.Add(new StrategySignalDto( + Type: "RsiOversoldRebound", + Timestamp: candle.Timestamp, + Direction: "BUY", + Price: candle.Close, + Description: "RSI (14) steigt aus überverkauftem Bereich (<30) wieder an." + )); + } + else if (rsi14[i - 1]!.Value > 70 && rsi14[i]!.Value <= 70) + { + signals.Add(new StrategySignalDto( + Type: "RsiOverboughtCorrection", + Timestamp: candle.Timestamp, + Direction: "SELL", + Price: candle.Close, + Description: "RSI (14) fällt aus überkauftem Bereich (>70) zurück." + )); + } + } + } + } + + private static void DetectTrianglePatterns(List sortedCandles, List patterns, string curSym) + { + if (sortedCandles.Count < 20) return; + + int[] windowSizes = { 20, 30, 45, 60, 90, 120 }; + var candidatePatterns = new List(); + + foreach (var window in windowSizes) + { + if (sortedCandles.Count < window) continue; + var slice = sortedCandles.TakeLast(window).ToList(); + + DetectDoubleBottomInSlice(slice, candidatePatterns, curSym); + DetectDoubleTopInSlice(slice, candidatePatterns, curSym); + DetectHeadAndShouldersInSlice(slice, candidatePatterns, curSym); + DetectTrianglesInSlice(slice, candidatePatterns, curSym); + } + + if (candidatePatterns.Count == 0) return; + + var currentClose = sortedCandles.Last().Close; + + bool activeSellBreakdown = candidatePatterns.Any(p => + p.BreakoutSignal?.Direction == "SELL" && + currentClose < p.BreakoutSignal.TriggerPrice); + + bool activeBuyBreakout = candidatePatterns.Any(p => + p.BreakoutSignal?.Direction == "BUY" && + currentClose > p.BreakoutSignal.TriggerPrice); + + var filteredPatterns = candidatePatterns.Where(p => + { + var isBuy = p.BreakoutSignal?.Direction == "BUY"; + var trigger = p.BreakoutSignal?.TriggerPrice ?? 0m; + + if (activeSellBreakdown && isBuy && currentClose < trigger) + return false; + + if (activeBuyBreakout && !isBuy && currentClose > trigger) + return false; + + return true; + }).ToList(); + + var distinctPatterns = filteredPatterns + .GroupBy(p => p.Type) + .Select(g => g.OrderByDescending(p => p.ConfidencePercent ?? 0m).First()) + .OrderByDescending(p => p.ConfidencePercent ?? 0m) + .ToList(); + + patterns.Clear(); + patterns.AddRange(distinctPatterns); + } + + private static List FindPivotLows(List candles, int lookback = 3) + { + var result = new List(); + for (int i = lookback; i < candles.Count - lookback; i++) + { + var low = candles[i].Low; + bool isPivot = true; + for (int j = i - lookback; j <= i + lookback; j++) + { + if (j == i) continue; + if (candles[j].Low <= low) { isPivot = false; break; } + } + if (isPivot) result.Add(i); + } + return result; + } + + private static List FindPivotHighs(List candles, int lookback = 3) + { + var result = new List(); + for (int i = lookback; i < candles.Count - lookback; i++) + { + var high = candles[i].High; + bool isPivot = true; + for (int j = i - lookback; j <= i + lookback; j++) + { + if (j == i) continue; + if (candles[j].High >= high) { isPivot = false; break; } + } + if (isPivot) result.Add(i); + } + return result; + } + + private static void DetectDoubleBottomInSlice(List slice, List patterns, string curSym) + { + if (slice.Count < 15) return; + var currentClose = slice.Last().Close; + var maxRecentHigh = slice.Max(c => c.High); + + int lookback = slice.Count >= 45 ? 3 : 2; + var pivotLows = FindPivotLows(slice, lookback); + if (pivotLows.Count < 2) return; + + for (int a = 0; a < pivotLows.Count - 1; a++) + { + for (int b = a + 1; b < pivotLows.Count; b++) + { + int idx1 = pivotLows[a]; + int idx2 = pivotLows[b]; + if (idx2 - idx1 < 5) continue; + + decimal low1 = slice[idx1].Low; + decimal low2 = slice[idx2].Low; + + if (Math.Abs(low1 - low2) / Math.Max(low1, low2) > 0.05m) continue; + + decimal neckline = 0m; + for (int k = idx1; k <= idx2; k++) + if (slice[k].High > neckline) neckline = slice[k].High; + + decimal avgLow = (low1 + low2) / 2m; + if (neckline < avgLow * 1.02m) continue; + + var targetPrice = neckline + (neckline - avgLow); + + if (maxRecentHigh >= targetPrice) continue; + if (currentClose < avgLow * 0.97m) continue; + + bool breakoutConfirmed = maxRecentHigh >= neckline * 1.01m; + if (breakoutConfirmed && currentClose < neckline) continue; + if (!breakoutConfirmed && currentClose < neckline * 0.90m) continue; + + DateTime breakoutTime = slice.Last().Timestamp; + for (int k = idx2 + 1; k < slice.Count; k++) + { + if (slice[k].High >= neckline || slice[k].Close >= neckline) + { + breakoutTime = slice[k].Timestamp; + break; + } + } + + var diffRatio = Math.Abs(low1 - low2) / Math.Max(low1, low2); + var neckDistRatio = (neckline - avgLow) / avgLow; + var conf = Math.Round(Math.Max(70m, 98m - (diffRatio * 600m) + (neckDistRatio * 200m)), 1); + conf = Math.Min(conf, 99m); + + var pct = currentClose > 0m ? ((targetPrice - currentClose) / currentClose) * 100m : 0m; + + string status = breakoutConfirmed + ? $"Ausbruch über {neckline:F2} {curSym} erfolgt (Kurs bei {currentClose:F2} {curSym}). Signal aktiv." + : $"Warten auf Ausbruch über Nackenlinie {neckline:F2} {curSym} (Trigger)."; + + DateTime futureTime = slice.Last().Timestamp.AddDays(14); + + double daysBetweenTiefs = (slice[idx2].Timestamp - slice[idx1].Timestamp).TotalDays; + if (daysBetweenTiefs <= 0) daysBetweenTiefs = 1; + double lowerSlope = (double)(low2 - low1) / daysBetweenTiefs; + double daysToFuture = (futureTime - slice[idx1].Timestamp).TotalDays; + decimal projectedLowerPrice = low1 + (decimal)(lowerSlope * daysToFuture); + + patterns.Add(new ChartPatternDto( + Type: "DoubleBottom", + Description: $"Doppel-Tief (W-Muster): Bullische Bodenformation. Zwei Tiefs bei ~{avgLow:F2} {curSym} getestet. {status}", + UpperLine: new List + { + new(slice[idx1].Timestamp, neckline), + new(futureTime, neckline) + }, + LowerLine: new List + { + new(slice[idx1].Timestamp, low1), + new(slice[idx2].Timestamp, low2), + new(futureTime, projectedLowerPrice) + }, + ApexTime: null, + BreakoutSignal: new BreakoutSignalDto( + Time: breakoutTime, + Direction: "BUY", + TriggerPrice: neckline, + TargetPrice: targetPrice, + PotentialPercent: pct), + ConfidencePercent: conf)); + return; + } + } + } + + private static void DetectDoubleTopInSlice(List slice, List patterns, string curSym) + { + if (slice.Count < 15) return; + var currentClose = slice.Last().Close; + var minRecentLow = slice.Min(c => c.Low); + + int lookback = slice.Count >= 45 ? 3 : 2; + var pivotHighs = FindPivotHighs(slice, lookback); + if (pivotHighs.Count < 2) return; + + for (int a = 0; a < pivotHighs.Count - 1; a++) + { + for (int b = a + 1; b < pivotHighs.Count; b++) + { + int idx1 = pivotHighs[a]; + int idx2 = pivotHighs[b]; + if (idx2 - idx1 < 5) continue; + + decimal high1 = slice[idx1].High; + decimal high2 = slice[idx2].High; + + if (Math.Abs(high1 - high2) / Math.Max(high1, high2) > 0.05m) continue; + + decimal neckline = decimal.MaxValue; + for (int k = idx1; k <= idx2; k++) + if (slice[k].Low < neckline) neckline = slice[k].Low; + + decimal avgHigh = (high1 + high2) / 2m; + if (neckline > avgHigh * 0.98m) continue; + + var targetPrice = neckline - (avgHigh - neckline); + + if (minRecentLow <= targetPrice) continue; + if (currentClose > avgHigh * 1.03m) continue; + + bool breakdownConfirmed = minRecentLow <= neckline * 0.99m; + if (breakdownConfirmed && currentClose > neckline) continue; + if (!breakdownConfirmed && currentClose > neckline * 1.10m) continue; + + DateTime breakdownTime = slice.Last().Timestamp; + for (int k = idx2 + 1; k < slice.Count; k++) + { + if (slice[k].Low <= neckline || slice[k].Close <= neckline) + { + breakdownTime = slice[k].Timestamp; + break; + } + } + + var diffRatio = Math.Abs(high1 - high2) / Math.Max(high1, high2); + var neckDistRatio = (avgHigh - neckline) / avgHigh; + var conf = Math.Round(Math.Max(70m, 97m - (diffRatio * 600m) + (neckDistRatio * 200m)), 1); + conf = Math.Min(conf, 99m); + + var pct = currentClose > 0m ? ((currentClose - targetPrice) / currentClose) * 100m : 0m; + + string status = breakdownConfirmed + ? $"Breakdown unter {neckline:F2} {curSym} erfolgt (Kurs bei {currentClose:F2} {curSym}). Signal aktiv." + : $"Warten auf Breakdown unter Nackenlinie {neckline:F2} {curSym} (Trigger)."; + + DateTime futureTime = slice.Last().Timestamp.AddDays(14); + + double daysBetweenHighs = (slice[idx2].Timestamp - slice[idx1].Timestamp).TotalDays; + if (daysBetweenHighs <= 0) daysBetweenHighs = 1; + double upperSlope = (double)(high2 - high1) / daysBetweenHighs; + double daysToFuture = (futureTime - slice[idx1].Timestamp).TotalDays; + decimal projectedUpperPrice = high1 + (decimal)(upperSlope * daysToFuture); + + patterns.Add(new ChartPatternDto( + Type: "DoubleTop", + Description: $"Doppel-Top (M-Muster): Bearische Umkehrformation. Widerstand bei ~{avgHigh:F2} {curSym} zweimal abgeprallt. {status}", + UpperLine: new List + { + new(slice[idx1].Timestamp, high1), + new(slice[idx2].Timestamp, high2), + new(futureTime, projectedUpperPrice) + }, + LowerLine: new List + { + new(slice[idx1].Timestamp, neckline), + new(futureTime, neckline) + }, + ApexTime: null, + BreakoutSignal: new BreakoutSignalDto( + Time: breakdownTime, + Direction: "SELL", + TriggerPrice: neckline, + TargetPrice: targetPrice, + PotentialPercent: pct), + ConfidencePercent: conf)); + return; + } + } + } + + private static void DetectHeadAndShouldersInSlice(List slice, List patterns, string curSym) + { + if (slice.Count < 20) return; + var currentClose = slice.Last().Close; + var minRecentLow = slice.Min(c => c.Low); + + int lookback = slice.Count >= 60 ? 4 : 3; + var pivotHighs = FindPivotHighs(slice, lookback); + if (pivotHighs.Count < 3) return; + + for (int a = 0; a < pivotHighs.Count - 2; a++) + { + int lsIdx = pivotHighs[a]; + int headIdx = pivotHighs[a + 1]; + int rsIdx = pivotHighs[a + 2]; + + decimal ls = slice[lsIdx].High; + decimal head = slice[headIdx].High; + decimal rs = slice[rsIdx].High; + + if (head <= ls * 1.01m || head <= rs * 1.01m) continue; + if (Math.Abs(ls - rs) / Math.Max(ls, rs) > 0.06m) continue; + + decimal neckline = decimal.MaxValue; + for (int k = lsIdx; k <= rsIdx; k++) + if (slice[k].Low < neckline) neckline = slice[k].Low; + + var targetPrice = neckline - (head - neckline); + + if (minRecentLow <= targetPrice) continue; + if (currentClose > head * 1.03m) continue; + + bool breakdownConfirmed = minRecentLow <= neckline * 0.99m; + if (breakdownConfirmed && currentClose > neckline) continue; + if (!breakdownConfirmed && currentClose > neckline * 1.10m) continue; + + DateTime breakdownTime = slice.Last().Timestamp; + for (int k = rsIdx + 1; k < slice.Count; k++) + { + if (slice[k].Low <= neckline || slice[k].Close <= neckline) + { + breakdownTime = slice[k].Timestamp; + break; + } + } + + var diffRatio = Math.Abs(ls - rs) / Math.Max(ls, rs); + var conf = Math.Round(Math.Max(72m, 96m - (diffRatio * 500m)), 1); + conf = Math.Min(conf, 99m); + + var pct = currentClose > 0m ? ((currentClose - targetPrice) / currentClose) * 100m : 0m; + + string status = breakdownConfirmed + ? $"Breakdown unter {neckline:F2} {curSym} erfolgt (Kurs bei {currentClose:F2} {curSym}). Signal aktiv." + : $"Warten auf Breakdown unter Nackenlinie {neckline:F2} {curSym} (Trigger)."; + + DateTime futureTime = slice.Last().Timestamp.AddDays(14); + + double daysBetweenShoulders = (slice[rsIdx].Timestamp - slice[lsIdx].Timestamp).TotalDays; + if (daysBetweenShoulders <= 0) daysBetweenShoulders = 1; + double upperSlope = (double)(rs - ls) / daysBetweenShoulders; + double daysToFuture = (futureTime - slice[lsIdx].Timestamp).TotalDays; + decimal projectedUpperPrice = ls + (decimal)(upperSlope * daysToFuture); + + patterns.Add(new ChartPatternDto( + Type: "HeadAndShoulders", + Description: $"Kopf-Schulter-Formation: Bearische Trendumkehr. Kopf bei {head:F2} {curSym}, Nackenlinie bei {neckline:F2} {curSym} (Trigger). {status}", + UpperLine: new List + { + new(slice[lsIdx].Timestamp, ls), + new(slice[rsIdx].Timestamp, rs), + new(futureTime, projectedUpperPrice) + }, + LowerLine: new List + { + new(slice[lsIdx].Timestamp, neckline), + new(futureTime, neckline) + }, + ApexTime: null, + BreakoutSignal: new BreakoutSignalDto( + Time: breakdownTime, + Direction: "SELL", + TriggerPrice: neckline, + TargetPrice: targetPrice, + PotentialPercent: pct), + ConfidencePercent: conf)); + return; + } + } + + private static void DetectTrianglesInSlice(List slice, List patterns, string curSym) + { + if (slice.Count < 10) return; + + var startTime = slice[0].Timestamp; + var endTime = slice[^1].Timestamp; + var lastPrice = slice[^1].Close; + var maxRecentHigh = slice.Max(c => c.High); + var minRecentLow = slice.Min(c => c.Low); + + int third = slice.Count / 3; + var first = slice.Take(third).ToList(); + var last = slice.TakeLast(third).ToList(); + + decimal high1 = first.Max(c => c.High); + decimal high2 = last.Max(c => c.High); + decimal low1 = first.Min(c => c.Low); + decimal low2 = last.Min(c => c.Low); + + decimal triangleBaseHeight = Math.Max(0.5m, high1 - low1); + + double totalDays = (endTime - startTime).TotalDays; + if (totalDays <= 0) totalDays = 10; + + DateTime apexTime = endTime.AddDays(10); + double mUpper = (double)(high2 - high1) / totalDays; + double mLower = (double)(low2 - low1) / totalDays; + + if (Math.Abs(mUpper - mLower) > 0.00001) + { + double daysToApex = (double)(low1 - high1) / (mUpper - mLower); + if (daysToApex > 0 && daysToApex < 120) + { + apexTime = startTime.AddDays(daysToApex); + } + } + + if (high2 >= high1 * 0.97m && high2 <= high1 * 1.03m && low2 > low1 * 1.01m) + { + var resistance = (high1 + high2) / 2m; + var targetPrice = resistance + triangleBaseHeight; + + bool breakoutConfirmed = maxRecentHigh >= resistance * 1.01m; + bool isValid = maxRecentHigh < targetPrice && lastPrice >= low1 * 0.97m; + if (breakoutConfirmed && lastPrice < resistance) isValid = false; + + if (isValid && !patterns.Any(p => p.Type == "AscendingTriangle")) + { + var pct = lastPrice > 0m ? ((targetPrice - lastPrice) / lastPrice) * 100m : 0m; + var conf = Math.Round(Math.Max(70m, 93m - (Math.Abs(high1 - high2) / high1) * 600m), 1); + + patterns.Add(new ChartPatternDto( + Type: "AscendingTriangle", + Description: $"Steigendes Dreieck: Flacher Widerstand bei {resistance:F2} {curSym} (Trigger) mit steigenden Tiefs — bullisches Konsolidierungsmuster.", + UpperLine: new List { new(startTime, resistance), new(apexTime, resistance) }, + LowerLine: new List { new(startTime, low1), new(apexTime, resistance) }, + ApexTime: apexTime, + BreakoutSignal: new BreakoutSignalDto(Time: endTime, Direction: "BUY", TriggerPrice: resistance, TargetPrice: targetPrice, PotentialPercent: pct), + ConfidencePercent: conf)); + } + } + + if (low2 >= low1 * 0.97m && low2 <= low1 * 1.03m && high2 < high1 * 0.99m) + { + var support = (low1 + low2) / 2m; + var targetPrice = Math.Max(0.01m, support - triangleBaseHeight); + + bool breakdownConfirmed = minRecentLow <= support * 0.99m; + bool isValid = minRecentLow > targetPrice && lastPrice <= high1 * 1.03m; + if (breakdownConfirmed && lastPrice > support) isValid = false; + + if (isValid && !patterns.Any(p => p.Type == "DescendingTriangle")) + { + var pct = lastPrice > 0m ? ((lastPrice - targetPrice) / lastPrice) * 100m : 0m; + var conf = Math.Round(Math.Max(70m, 93m - (Math.Abs(low1 - low2) / low1) * 600m), 1); + + patterns.Add(new ChartPatternDto( + Type: "DescendingTriangle", + Description: $"Fallendes Dreieck: Flache Unterstützung bei {support:F2} {curSym} (Trigger) mit fallenden Hochs — bearisches Konsolidierungsmuster.", + UpperLine: new List { new(startTime, high1), new(apexTime, support) }, + LowerLine: new List { new(startTime, support), new(apexTime, support) }, + ApexTime: apexTime, + BreakoutSignal: new BreakoutSignalDto(Time: endTime, Direction: "SELL", TriggerPrice: support, TargetPrice: targetPrice, PotentialPercent: pct), + ConfidencePercent: conf)); + } + } + + if (high2 < high1 * 0.99m && low2 > low1 * 1.01m) + { + if (!patterns.Any(p => p.Type == "SymmetricalTriangle")) + { + var direction = lastPrice >= (high1 + low1) / 2m ? "BUY" : "SELL"; + var targetPrice = direction == "BUY" + ? lastPrice + triangleBaseHeight + : Math.Max(0.01m, lastPrice - triangleBaseHeight); + + var pct = lastPrice > 0m + ? (direction == "BUY" ? ((targetPrice - lastPrice) / lastPrice) : ((lastPrice - targetPrice) / lastPrice)) * 100m + : 0m; + + decimal apexPrice = (high2 + low2) / 2m; + + patterns.Add(new ChartPatternDto( + Type: "SymmetricalTriangle", + Description: $"Symmetrisches Dreieck: Konvergierende Hochs und Tiefs — dynamischer Ausbruch in Trendrichtung erwartet.", + UpperLine: new List { new(startTime, high1), new(apexTime, apexPrice) }, + LowerLine: new List { new(startTime, low1), new(apexTime, apexPrice) }, + ApexTime: apexTime, + BreakoutSignal: new BreakoutSignalDto(Time: endTime, Direction: direction, TriggerPrice: lastPrice, TargetPrice: targetPrice, PotentialPercent: pct), + ConfidencePercent: 85m)); + } + } + } +} \ No newline at end of file diff --git a/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs new file mode 100644 index 0000000..e4d4081 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services.TradeRepublic; +using FinlyticTechnicalAnalysis.Database; +using FinlyticTechnicalAnalysis.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FinlyticTechnicalAnalysis.Services; + +public interface ITechnicalAnalysisDbService +{ + Task GetAnalysisAsync(string isin, bool forceRefresh = false, CancellationToken cancellationToken = default); + Task GetLivePriceAsync(string isin, CancellationToken cancellationToken = default); +} + +public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IYahooMarketDataScraper _yahooScraper; + private readonly ITradeRepublicService _trService; + private readonly ITechnicalAnalysisCalculator _calculator; + private readonly ILogger _logger; + + // Cache Layer 1: In-Memory Candles Cache (TTL: 15 Minuten) + private static readonly ConcurrentDictionary Candles, string Symbol, string Currency, DateTime FetchedAt)> _candleCache = new(); + + // Per-ISIN Semaphores zur Vermeidung von Cache-Stampedes + private static readonly ConcurrentDictionary _perIsinLocks = new(); + private static readonly TimeSpan CandleCacheTtl = TimeSpan.FromMinutes(15); + private static readonly TimeSpan DbCacheTtl = TimeSpan.FromHours(1); + + public TechnicalAnalysisDbService( + IServiceScopeFactory scopeFactory, + IYahooMarketDataScraper yahooScraper, + ITradeRepublicService trService, + ITechnicalAnalysisCalculator calculator, + ILogger logger) + { + _scopeFactory = scopeFactory; + _yahooScraper = yahooScraper; + _trService = trService; + _calculator = calculator; + _logger = logger; + } + + public async Task GetAnalysisAsync(string isin, bool forceRefresh = false, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + var cleanIsin = isin.Trim().ToUpperInvariant(); + + // 1. Layer-1: Fast-Path aus In-Memory Cache (wenn kein forceRefresh) + if (!forceRefresh && _candleCache.TryGetValue(cleanIsin, out var ramEntry) && DateTime.UtcNow - ramEntry.FetchedAt < CandleCacheTtl) + { + _logger.LogDebug("[{Channel}] RAM-Cache Hit for ISIN {Isin}. Merging live price...", "TechnicalAnalysisChannel", cleanIsin); + return await BuildAnalysisWithLivePriceAsync(cleanIsin, ramEntry.Candles, ramEntry.Symbol, ramEntry.Currency, cancellationToken); + } + + // Semaphor für ISIN holen (verhindert doppelte parallele Abfragen der gleichen ISIN) + var semaphore = _perIsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + + try + { + // Re-Check nach Lock-Erhalt + if (!forceRefresh && _candleCache.TryGetValue(cleanIsin, out ramEntry) && DateTime.UtcNow - ramEntry.FetchedAt < CandleCacheTtl) + { + return await BuildAnalysisWithLivePriceAsync(cleanIsin, ramEntry.Candles, ramEntry.Symbol, ramEntry.Currency, cancellationToken); + } + + // 2. Layer-2: Prüfen ob frische Daten in der Datenbank liegen + if (!forceRefresh) + { + var dbDto = await GetFromDbCacheAsync(cleanIsin, cancellationToken); + if (dbDto != null) + { + _logger.LogDebug("[{Channel}] DB-Cache Hit for ISIN {Isin}.", "TechnicalAnalysisChannel", cleanIsin); + return dbDto; + } + } + + // 3. Cache Miss / ForceRefresh: Vollständige Neuberechnung + return await FullRefreshAsync(cleanIsin, cancellationToken); + } + finally + { + semaphore.Release(); + // Speicher aufräumen, falls Lock nicht mehr genutzt wird + if (semaphore.CurrentCount == 1) + { + _perIsinLocks.TryRemove(cleanIsin, out _); + } + } + } + + public async Task GetLivePriceAsync(string isin, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + var cleanIsin = isin.Trim().ToUpperInvariant(); + + var (livePrice, liveBid, liveAsk) = await FetchLivePriceAsync(cleanIsin, cancellationToken); + if (!livePrice.HasValue) return null; + + return new LivePriceDto( + cleanIsin, + Math.Round(livePrice.Value, 2), + 0m, // Percent change optional + liveBid.HasValue ? Math.Round(liveBid.Value, 2) : null, + liveAsk.HasValue ? Math.Round(liveAsk.Value, 2) : null + ); + } + + private async Task FullRefreshAsync(string cleanIsin, CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Full refresh for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + + var tickerTask = _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); + var macroTask = FetchMacroDataAsync(cancellationToken); + + await Task.WhenAll(tickerTask, macroTask); + + var ticker = await tickerTask; + var querySymbol = !string.IsNullOrEmpty(ticker) ? ticker : cleanIsin; + var (vix, gspc, dxy) = await macroTask; + + var yahooResult = await _yahooScraper.FetchHistoricalCandlesWithCurrencyAsync(querySymbol, "1y", "1d", cancellationToken); + var candles = yahooResult.Candles; + var currency = yahooResult.Currency; + + if (candles.Count == 0 && querySymbol != cleanIsin) + { + yahooResult = await _yahooScraper.FetchHistoricalCandlesWithCurrencyAsync(cleanIsin, "1y", "1d", cancellationToken); + candles = yahooResult.Candles; + currency = yahooResult.Currency; + } + + if (candles.Count == 0) + { + _logger.LogWarning("[{Channel}] No candles retrieved for {Symbol}", "TechnicalAnalysisChannel", querySymbol); + return null; + } + + // In RAM-Cache sichern + _candleCache[cleanIsin] = (candles.Select(CloneCandle).ToList(), querySymbol, currency, DateTime.UtcNow); + + // Live-Preis einpflegen + await MergeLivePriceAsync(cleanIsin, candles, querySymbol, cancellationToken); + + var resultDto = BuildDto(cleanIsin, querySymbol, currency, candles, vix, gspc, dxy); + + // Synchron und sicher in DB persistieren + await PersistToDbCacheAsync(cleanIsin, querySymbol, resultDto, cancellationToken); + + return resultDto; + } + + private async Task BuildAnalysisWithLivePriceAsync( + string cleanIsin, List cachedCandles, string querySymbol, string currency, CancellationToken cancellationToken) + { + var candles = cachedCandles.Select(CloneCandle).ToList(); + + var livePriceTask = FetchLivePriceAsync(cleanIsin, cancellationToken); + var macroTask = FetchMacroDataAsync(cancellationToken); + await Task.WhenAll(livePriceTask, macroTask); + + var (livePrice, liveBid, liveAsk) = await livePriceTask; + var (vix, gspc, dxy) = await macroTask; + + if (livePrice.HasValue && livePrice.Value > 0m) + { + var today = DateTime.UtcNow.Date; + var lastCandle = candles.LastOrDefault(c => c.Timestamp.Date == today); + if (lastCandle != null) + { + lastCandle.Close = livePrice.Value; + lastCandle.High = Math.Max(lastCandle.High, livePrice.Value); + lastCandle.Low = Math.Min(lastCandle.Low, livePrice.Value); + if (liveBid.HasValue) lastCandle.Bid = liveBid.Value; + if (liveAsk.HasValue) lastCandle.Ask = liveAsk.Value; + } + else + { + var prevClose = candles.LastOrDefault()?.Close ?? livePrice.Value; + candles.Add(new MarketCandleEntity + { + Symbol = querySymbol, Interval = "1d", Timestamp = today, + Open = prevClose, High = Math.Max(prevClose, livePrice.Value), + Low = Math.Min(prevClose, livePrice.Value), Close = livePrice.Value, + Volume = 1000, Bid = liveBid, Ask = liveAsk + }); + } + } + + return BuildDto(cleanIsin, querySymbol, currency, candles, vix, gspc, dxy); + } + + private async Task MergeLivePriceAsync(string cleanIsin, List candles, string querySymbol, CancellationToken cancellationToken) + { + var (livePrice, liveBid, liveAsk) = await FetchLivePriceAsync(cleanIsin, cancellationToken); + if (!livePrice.HasValue || livePrice.Value <= 0m) return; + + var today = DateTime.UtcNow.Date; + var lastCandle = candles.LastOrDefault(c => c.Timestamp.Date == today); + if (lastCandle != null) + { + lastCandle.Close = livePrice.Value; + lastCandle.High = Math.Max(lastCandle.High, livePrice.Value); + lastCandle.Low = Math.Min(lastCandle.Low, livePrice.Value); + if (liveBid.HasValue) lastCandle.Bid = liveBid.Value; + if (liveAsk.HasValue) lastCandle.Ask = liveAsk.Value; + } + else + { + var prevClose = candles.LastOrDefault()?.Close ?? livePrice.Value; + candles.Add(new MarketCandleEntity + { + Symbol = querySymbol, Interval = "1d", Timestamp = today, + Open = prevClose, High = Math.Max(prevClose, livePrice.Value), + Low = Math.Min(prevClose, livePrice.Value), Close = livePrice.Value, + Volume = 1000, Bid = liveBid, Ask = liveAsk + }); + } + } + + private async Task<(decimal? livePrice, decimal? liveBid, decimal? liveAsk)> FetchLivePriceAsync(string cleanIsin, CancellationToken cancellationToken) + { + decimal? livePrice = null; + decimal? liveBid = null; + decimal? liveAsk = null; + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(1500); // Maximal 1.5 Sekunden Wartezeit auf Ticker + + var trTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + int? subId = await _trService.SubscribeRealtimeTickerAsync(cleanIsin, tick => + { + if (tick.Last != null && tick.Last.PriceValue > 0m) + { + livePrice = tick.Last.PriceValue; + liveBid = tick.Bid?.PriceValue; + liveAsk = tick.Ask?.PriceValue; + trTask.TrySetResult(true); + } + }, cts.Token); + + if (subId.HasValue) + { + try + { + await trTask.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) { } + + await _trService.UnsubscribeRealtimeTickerAsync(subId.Value); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Real-time price fetch skipped for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + } + + return (livePrice, liveBid, liveAsk); + } + + private async Task<(MacroDataEntity vix, MacroDataEntity gspc, MacroDataEntity dxy)> FetchMacroDataAsync(CancellationToken cancellationToken) + { + var vixTask = _yahooScraper.FetchMacroTickerAsync("^VIX", cancellationToken); + var gspcTask = _yahooScraper.FetchMacroTickerAsync("^GSPC", cancellationToken); + var dxyTask = _yahooScraper.FetchMacroTickerAsync("DX-Y.NY", cancellationToken); + + await Task.WhenAll(vixTask, gspcTask, dxyTask); + + var vix = await vixTask ?? new MacroDataEntity { Symbol = "^VIX", Value = 18.5m, TrendState = "Moderate" }; + var gspc = await gspcTask ?? new MacroDataEntity { Symbol = "^GSPC", Value = 5500m, TrendState = "Bullish" }; + var dxy = await dxyTask ?? new MacroDataEntity { Symbol = "DX-Y.NY", Value = 104.2m, TrendState = "Neutral" }; + + return (vix, gspc, dxy); + } + + private TechnicalAnalysisDto BuildDto(string cleanIsin, string querySymbol, string currency, List candles, MacroDataEntity vix, MacroDataEntity gspc, MacroDataEntity dxy) + { + var vixRegime = vix.Value > 25m ? "HighVolatility" : (vix.Value > 18m ? "Moderate" : "LowVolatility"); + var summaryText = $"Markt-Vola (VIX: {vix.Value:F1}) ist {vixRegime}. S&P 500 Trend ist {gspc.TrendState}. DXY: {dxy.Value:F1}."; + + var marketRegime = new MarketRegimeDto( + VixValue: vix.Value, VixRegime: vixRegime, + MarketTrend: gspc.TrendState, DxyValue: dxy.Value, + DxyState: dxy.TrendState == "Bullish" ? "DollarStrengthening" : "DollarWeakening", + SummaryText: summaryText); + + var (indicators, patterns, signals) = _calculator.CalculateAnalysis(candles, currency); + + var candleDtos = candles.Select(c => new CandleDto( + Timestamp: c.Timestamp, Open: c.Open, High: c.High, + Low: c.Low, Close: c.Close, Volume: c.Volume, + Bid: c.Bid, Ask: c.Ask)).ToList(); + + return new TechnicalAnalysisDto( + Isin: cleanIsin, Ticker: querySymbol, CompanyName: querySymbol, + LastUpdated: DateTime.UtcNow, Candles: candleDtos, + Indicators: indicators, Patterns: patterns, Signals: signals, + MarketRegime: marketRegime, Currency: currency); + } + + private async Task GetFromDbCacheAsync(string cleanIsin, CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var cached = await db.CachedAnalyses + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Isin == cleanIsin, cancellationToken); + + if (cached != null && DateTime.UtcNow - cached.CalculatedAt < DbCacheTtl) + { + return JsonSerializer.Deserialize(cached.AnalysisJson); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Failed to read DB cache for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + } + + return null; + } + + private async Task PersistToDbCacheAsync(string cleanIsin, string querySymbol, TechnicalAnalysisDto dto, CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var json = JsonSerializer.Serialize(dto); + + var existing = await db.CachedAnalyses.FirstOrDefaultAsync(c => c.Isin == cleanIsin, cancellationToken); + if (existing != null) + { + existing.Ticker = querySymbol; + existing.AnalysisJson = json; + existing.CalculatedAt = DateTime.UtcNow; + } + else + { + db.CachedAnalyses.Add(new CachedAnalysisEntity + { + Isin = cleanIsin, + Ticker = querySymbol, + AnalysisJson = json, + CalculatedAt = DateTime.UtcNow + }); + } + + await db.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Failed to persist TA DB cache for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + } + } + + private static MarketCandleEntity CloneCandle(MarketCandleEntity c) => new() + { + Symbol = c.Symbol, Interval = c.Interval, Timestamp = c.Timestamp, + Open = c.Open, High = c.High, Low = c.Low, Close = c.Close, + Volume = c.Volume, Bid = c.Bid, Ask = c.Ask + }; +} \ No newline at end of file diff --git a/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs b/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs new file mode 100644 index 0000000..f3b1637 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Services.Yahoo; +using FinlyticTechnicalAnalysis.Entities; +using Microsoft.Extensions.Logging; + +namespace FinlyticTechnicalAnalysis.Services; + +public record YahooCandlesResult( + List Candles, + string Currency +); + +public interface IYahooMarketDataScraper +{ + /// + /// Resolves ticker from ISIN. + /// + Task ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default); + + /// + /// Fetches historical candles. + /// + Task> FetchHistoricalCandlesAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default); + + /// + /// Fetches historical candles with currency. + /// + Task FetchHistoricalCandlesWithCurrencyAsync(string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default); + + /// + /// Fetches macro ticker. + /// + Task FetchMacroTickerAsync(string symbol, CancellationToken cancellationToken = default); +} + +public class YahooMarketDataScraper : IYahooMarketDataScraper +{ + private readonly YahooFinanceClient _yahooClient; + private readonly ILogger _logger; + + public YahooMarketDataScraper(YahooFinanceClient yahooClient, ILogger logger) + { + _yahooClient = yahooClient; + _logger = logger; + } + + /// + /// Resolves ticker from ISIN using Yahoo Search API. + /// + 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; + } + + try + { + var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken); + if (searchResult?.Quotes != null && searchResult.Quotes.Count > 0) + { + var symbolList = searchResult.Quotes + .Select(q => q.Symbol) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => s!) + .ToList(); + + if (symbolList.Count > 0) + { + if (cleanIsin.StartsWith("US", StringComparison.OrdinalIgnoreCase)) + { + var noDotSymbol = symbolList.FirstOrDefault(s => !s.Contains('.')); + if (noDotSymbol != null) return noDotSymbol; + } + return symbolList[0]; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Failed to resolve Yahoo ticker for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + } + + return null; + } + + /// + /// Fetches historical candles. + /// + 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; + } + + /// + /// Fetches historical candles with currency metadata using authenticated Crumb/Cookie flow. + /// + 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) + { + _logger.LogWarning("[{Channel}] No chart data returned from Yahoo Client for symbol {Symbol}", "TechnicalAnalysisChannel", symbol); + return new YahooCandlesResult(results, detectedCurrency); + } + + // Extract currency metadata + 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++) + { + 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; + + // Skip invalid or empty weekend/holiday records + if (close <= 0m && open <= 0m) continue; + + results.Add(new MarketCandleEntity + { + Symbol = symbol.ToUpperInvariant(), + Interval = interval, + Timestamp = dt, + Open = open, + High = Math.Max(high, Math.Max(open, close)), + Low = Math.Min(low, Math.Min(open, close)), + Close = close, + Volume = vol + }); + } + + _logger.LogInformation("[{Channel}] Successfully fetched {Count} candles for {Symbol} ({Range}, {Interval}, Currency: {Currency})", + "TechnicalAnalysisChannel", results.Count, symbol, range, interval, detectedCurrency); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error fetching historical candles for {Symbol}", "TechnicalAnalysisChannel", symbol); + } + + return new YahooCandlesResult(results, detectedCurrency); + } + + /// + /// Fetches macro ticker data (e.g., ^VIX, ^GSPC, DX-Y.NY). + /// + public async Task FetchMacroTickerAsync(string symbol, CancellationToken cancellationToken = default) + { + var candles = await FetchHistoricalCandlesAsync(symbol, "5d", "1d", cancellationToken); + if (candles.Count == 0) return null; + + var lastCandle = candles.Last(); + var prevCandle = candles.Count > 1 ? candles[^2] : lastCandle; + + var trendState = lastCandle.Close >= prevCandle.Close ? "Bullish" : "Bearish"; + if (symbol == "^VIX") + { + trendState = lastCandle.Close > 25m ? "HighVolatility" : (lastCandle.Close > 18m ? "Moderate" : "LowVolatility"); + } + + return new MacroDataEntity + { + Symbol = symbol, + Value = lastCandle.Close, + PreviousClose = prevCandle.Close, + TrendState = trendState, + LastUpdatedAt = DateTime.UtcNow + }; + } + + private static string FallbackCurrencyBySymbol(string symbol) + { + if (string.IsNullOrWhiteSpace(symbol)) return "EUR"; + + if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase) || + symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase) || + symbol.EndsWith(".VI", StringComparison.OrdinalIgnoreCase) || + symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) + { + return "EUR"; + } + + if (!symbol.Contains('.')) + { + return "USD"; + } + + return "EUR"; + } +} \ No newline at end of file diff --git a/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs b/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs new file mode 100644 index 0000000..45abfab --- /dev/null +++ b/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs @@ -0,0 +1,199 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models; +using FinlyticCore.Util; +using FinlyticTechnicalAnalysis.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticTechnicalAnalysis.Util; + +public class TAMqttClient( + ILogger logger, + IConfiguration configuration, + IServiceScopeFactory scopeFactory) : ManagedMqttClient(logger), IHostedService +{ + /// + /// Starts the MQTT client. + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + var host = configuration["MQTT:Host"] ?? configuration["MQTT__Host"] ?? "localhost"; + var portStr = configuration["MQTT:Port"] ?? configuration["MQTT__Port"] ?? "1883"; + var clientId = configuration["MQTT:ClientId"] ?? "finlytic_ta_" + Guid.NewGuid().ToString("N"); + + var config = new MqttConfiguration + { + Host = host, + Port = int.TryParse(portStr, out var p) ? p : 1883, + ClientId = clientId + }; + + logger.LogInformation("[{Channel}] Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", "TechnicalAnalysisChannel", config.Host, config.ClientId); + await ConnectAsync(config); + } + + /// + /// Stops the MQTT client. + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation("[{Channel}] Stopping Technical Analysis MQTT client.", "TechnicalAnalysisChannel"); + await DisconnectAsync(); + } + + protected override async Task OnConnectedAsync() + { + logger.LogInformation("[{Channel}] Technical Analysis MQTT client connected. Subscribing to RPC topic...", "TechnicalAnalysisChannel"); + await SubscribeAsync("services/request/ta_GetAnalysis/#"); + await SubscribeAsync("services/request/tr_GetLivePrice/#"); + await SubscribeAsync("services/request/health_Ping/#"); + await SubscribeAsync("services/config/updated/#"); + } + + protected override async Task OnMessageReceivedAsync(string topic, string payload) + { + if (string.IsNullOrWhiteSpace(topic)) return; + + if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase)) + { + await HandleConfigUpdatedAsync(topic, payload); + return; + } + + var segments = topic.Split('/'); + if (segments.Length < 4) return; + + var channel = segments[2]; + var correlationId = segments[segments.Length - 1]; + + if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + { + await HandleHealthPingAsync(topic, segments, correlationId); + return; + } + + if (channel == "ta_GetAnalysis") + { + await HandleGetAnalysisAsync(payload, correlationId); + } + else if (channel == "tr_GetLivePrice") + { + await HandleGetLivePriceAsync(payload, correlationId); + } + } + + private async Task HandleConfigUpdatedAsync(string topic, string payload) + { + if (!topic.EndsWith("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase)) + return; + + logger.LogInformation("[{Channel}] [TAMqttClient] Received config update event for FinlyticTechnicalAnalysis.", "TechnicalAnalysisChannel"); + try + { + var updatePayload = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload); + if (updatePayload?.Settings != null && updatePayload.Settings.Count > 0) + { + using var scope = scopeFactory.CreateScope(); + var settingsDb = scope.ServiceProvider.GetRequiredService(); + await settingsDb.UpdateSettingsFromDictionaryAsync(updatePayload.Settings); + logger.LogInformation("[{Channel}] [TAMqttClient] Persisted {Count} updated settings to FinlyticTechnicalAnalysis database.", "TechnicalAnalysisChannel", updatePayload.Settings.Count); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[{Channel}] [TAMqttClient] Error processing MQTT config update event.", "TechnicalAnalysisChannel"); + } + } + + private async Task HandleHealthPingAsync(string topic, string[] segments, string correlationId) + { + bool isForMe = segments.Length >= 5 + ? segments[3].Equals("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase) + : topic.Contains("FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase); + + if (isForMe) + { + string respTopic = $"services/response/health_Ping/{correlationId}"; + await PublishAsync(respTopic, new FinlyticCore.Dtos.ServiceHealthResponse("FinlyticTechnicalAnalysis", "Online", DateTime.UtcNow, "Connected")); + logger.LogInformation("[{Channel}] [TAMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "TechnicalAnalysisChannel", correlationId); + } + } + + private async Task HandleGetAnalysisAsync(string payload, string correlationId) + { + logger.LogInformation("[{Channel}] Received RPC ta_GetAnalysis request. CorrelationId: {CorrelationId}", "TechnicalAnalysisChannel", correlationId); + + var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.IsinRequest); + string responseTopic = $"services/response/ta_GetAnalysis/{correlationId}"; + + if (string.IsNullOrWhiteSpace(req?.Isin)) + { + logger.LogWarning("[{Channel}] Request missing mandatory ISIN parameter.", "TechnicalAnalysisChannel"); + await PublishAsync(responseTopic, null); + return; + } + + try + { + using var scope = scopeFactory.CreateScope(); + var taDbService = scope.ServiceProvider.GetRequiredService(); + + var analysis = await taDbService.GetAnalysisAsync(req.Isin, req.ForceRefresh); + + logger.LogInformation("[{Channel}] Publishing RPC response to {ResponseTopic}", "TechnicalAnalysisChannel", responseTopic); + await PublishAsync(responseTopic, analysis); + } + catch (Exception ex) + { + logger.LogError(ex, "[{Channel}] Failed to fetch technical analysis and publish RPC response for ISIN {Isin}", "TechnicalAnalysisChannel", req.Isin); + + // Antworte mit null, damit der Aufrufer nicht im RPC-Timeout verharrt + try + { + await PublishAsync(responseTopic, null); + } + catch { } + } + } + + private async Task HandleGetLivePriceAsync(string payload, string correlationId) + { + logger.LogInformation("[{Channel}] Received RPC tr_GetLivePrice request. CorrelationId: {CorrelationId}", "TechnicalAnalysisChannel", correlationId); + + var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.IsinRequest); + string responseTopic = $"services/response/tr_GetLivePrice/{correlationId}"; + + if (string.IsNullOrWhiteSpace(req?.Isin)) + { + logger.LogWarning("[{Channel}] tr_GetLivePrice request missing mandatory ISIN parameter.", "TechnicalAnalysisChannel"); + await PublishAsync(responseTopic, null); + return; + } + + try + { + using var scope = scopeFactory.CreateScope(); + var taDbService = scope.ServiceProvider.GetRequiredService(); + + var livePrice = await taDbService.GetLivePriceAsync(req.Isin); + + logger.LogInformation("[{Channel}] Publishing RPC response to {ResponseTopic} for ISIN {Isin}", "TechnicalAnalysisChannel", responseTopic, req.Isin); + await PublishAsync(responseTopic, livePrice); + } + catch (Exception ex) + { + logger.LogError(ex, "[{Channel}] Failed to fetch live price and publish RPC response for ISIN {Isin}", "TechnicalAnalysisChannel", req.Isin); + + try + { + await PublishAsync(responseTopic, null); + } + catch { } + } + } +} \ No newline at end of file diff --git a/FinlyticTechnicalAnalysis/appsettings.json b/FinlyticTechnicalAnalysis/appsettings.json new file mode 100644 index 0000000..5865715 --- /dev/null +++ b/FinlyticTechnicalAnalysis/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "FinlyticCore.Services.TradeRepublic.TradeRepublicClient": "Debug" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_ta;Username=admin;Password=admin" + }, + "MQTT": { + "Host": "localhost", + "Port": "4545", + "ClientId": "finlytic_ta" + } +}