diff --git a/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs b/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs index 056a344..6a396e5 100644 --- a/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs +++ b/FinlyticTechnicalAnalysis/Database/TechnicalAnalysisDbContext.cs @@ -1,10 +1,12 @@ +using FinlyticCore.Database; using FinlyticCore.Entities.Settings; using FinlyticTechnicalAnalysis.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; namespace FinlyticTechnicalAnalysis.Database; -public class TechnicalAnalysisDbContext : DbContext +public class TechnicalAnalysisDbContext : DbContext, ISettingsDbContext { public TechnicalAnalysisDbContext(DbContextOptions options) : base(options) { @@ -23,7 +25,7 @@ public class TechnicalAnalysisDbContext : DbContext modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); - entity.HasIndex(e => e.Key); + entity.HasIndex(e => e.Key).IsUnique(); }); modelBuilder.Entity() @@ -34,3 +36,13 @@ public class TechnicalAnalysisDbContext : DbContext .HasIndex(c => c.Isin); } } + +public class TechnicalAnalysisDbContextFactory : IDesignTimeDbContextFactory +{ + public TechnicalAnalysisDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=ta;Username=postgres;Password=postgres"); + return new TechnicalAnalysisDbContext(optionsBuilder.Options); + } +} diff --git a/FinlyticTechnicalAnalysis/Migrations/20260815183955_AddDynamicSettings.Designer.cs b/FinlyticTechnicalAnalysis/Migrations/20260815183955_AddDynamicSettings.Designer.cs new file mode 100644 index 0000000..985c812 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Migrations/20260815183955_AddDynamicSettings.Designer.cs @@ -0,0 +1,193 @@ +// +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("20260815183955_AddDynamicSettings")] + partial class AddDynamicSettings + { + /// + 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("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/20260815183955_AddDynamicSettings.cs b/FinlyticTechnicalAnalysis/Migrations/20260815183955_AddDynamicSettings.cs new file mode 100644 index 0000000..0bdaea2 --- /dev/null +++ b/FinlyticTechnicalAnalysis/Migrations/20260815183955_AddDynamicSettings.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTechnicalAnalysis.Migrations +{ + /// + public partial class AddDynamicSettings : 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.CreateIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DynamicSettings"); + } + } +} diff --git a/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs b/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs index a1a97b8..4d26447 100644 --- a/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs +++ b/FinlyticTechnicalAnalysis/Migrations/TechnicalAnalysisDbContextModelSnapshot.cs @@ -22,6 +22,37 @@ namespace FinlyticTechnicalAnalysis.Migrations 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("FinlyticTechnicalAnalysis.Entities.CachedAnalysisEntity", b => { b.Property("Isin") diff --git a/FinlyticTechnicalAnalysis/Program.cs b/FinlyticTechnicalAnalysis/Program.cs index 76bdf77..5ad6f1c 100644 --- a/FinlyticTechnicalAnalysis/Program.cs +++ b/FinlyticTechnicalAnalysis/Program.cs @@ -1,4 +1,6 @@ using System; +using FinlyticCore.Database; +using FinlyticCore.Services; using FinlyticCore.Services.TradeRepublic; using FinlyticCore.Services.Yahoo; using FinlyticTechnicalAnalysis.Database; @@ -8,13 +10,17 @@ 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"))); +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// Register Core Services & Logger +builder.Services.AddSingleton(); +builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); // Register HTTP Clients builder.Services.AddHttpClient() @@ -54,8 +60,7 @@ using (var scope = host.Services.CreateScope()) } catch (Exception ex) { - var logger = scope.ServiceProvider.GetRequiredService>(); - logger.LogError(ex, "[{Channel}] An error occurred during database migration on startup.", "TechnicalAnalysisChannel"); + Console.WriteLine($"Critical error during database migration: {ex.Message}"); } } diff --git a/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs index f038632..a65b11e 100644 --- a/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs +++ b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs @@ -6,12 +6,13 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; using FinlyticCore.Services.TradeRepublic; using FinlyticTechnicalAnalysis.Database; using FinlyticTechnicalAnalysis.Entities; +using FinlyticTechnicalAnalysis.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace FinlyticTechnicalAnalysis.Services; @@ -29,7 +30,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService private readonly IYahooMarketDataScraper _yahooScraper; private readonly ITradeRepublicService _trService; private readonly ITechnicalAnalysisCalculator _calculator; - private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; private static readonly ConcurrentDictionary Candles, string Symbol, string Currency, DateTime FetchedAt)> _candleCache = new(); private static readonly ConcurrentDictionary _perIsinLocks = new(); @@ -41,13 +42,13 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService IYahooMarketDataScraper yahooScraper, ITradeRepublicService trService, ITechnicalAnalysisCalculator calculator, - ILogger logger) + IFinlyticLogger finlyticLogger) { _scopeFactory = scopeFactory; _yahooScraper = yahooScraper; _trService = trService; _calculator = calculator; - _logger = logger; + _finlyticLogger = finlyticLogger; } public async Task GetAnalysisAsync(string isin, bool forceRefresh = false, string? ticker = null, @@ -56,12 +57,11 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService 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 && (string.IsNullOrWhiteSpace(ticker) || string.Equals(ramEntry.Symbol, ticker, StringComparison.OrdinalIgnoreCase))) { - _logger.LogDebug("[{Channel}] RAM-Cache Hit for ISIN {Isin}. Merging live price...", "TechnicalAnalysisChannel", cleanIsin); + await _finlyticLogger.LogDebugAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalAnalysisDbService] RAM-Cache Hit for ISIN {Isin}. Merging live price...", cleanIsin); return await BuildAnalysisWithLivePriceAsync(cleanIsin, ramEntry.Candles, ramEntry.Symbol, ramEntry.Currency, cancellationToken); } @@ -70,7 +70,6 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService try { - // Re-Check nach Lock-Erhalt if (!forceRefresh && _candleCache.TryGetValue(cleanIsin, out ramEntry) && DateTime.UtcNow - ramEntry.FetchedAt < CandleCacheTtl && (string.IsNullOrWhiteSpace(ticker) || string.Equals(ramEntry.Symbol, ticker, StringComparison.OrdinalIgnoreCase))) @@ -78,13 +77,12 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService 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, ticker, cancellationToken); if (dbDto != null) { - _logger.LogDebug("[{Channel}] DB-Cache Hit for ISIN {Isin}.", "TechnicalAnalysisChannel", cleanIsin); + await _finlyticLogger.LogDebugAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalAnalysisDbService] DB-Cache Hit for ISIN {Isin}.", cleanIsin); return dbDto; } } @@ -120,7 +118,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService private async Task FullRefreshAsync(string cleanIsin, string? requestedTicker, CancellationToken cancellationToken) { - _logger.LogInformation("[{Channel}] Full refresh for ISIN {Isin} (RequestedTicker: {Ticker})", "TechnicalAnalysisChannel", cleanIsin, requestedTicker ?? "None"); + await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalAnalysisDbService] Full refresh for ISIN {Isin} (RequestedTicker: {Ticker})", cleanIsin, requestedTicker ?? "None"); var macroTask = FetchMacroDataAsync(cancellationToken); @@ -133,7 +131,6 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService var querySymbol = !string.IsNullOrEmpty(ticker) ? ticker : cleanIsin; var (vix, gspc, dxy) = await macroTask; - // Lade 2y Daten für saubere Indikator-Aufwärmphasen var yahooResult = await _yahooScraper.FetchHistoricalCandlesWithCurrencyAsync(querySymbol, "2y", "1d", cancellationToken); var candles = yahooResult.Candles; var currency = yahooResult.Currency; @@ -147,7 +144,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService if (candles.Count == 0) { - _logger.LogWarning("[{Channel}] No candles retrieved for {Symbol}", "TechnicalAnalysisChannel", querySymbol); + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalAnalysisDbService] No candles retrieved for {Symbol}", querySymbol); return null; } @@ -173,7 +170,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService await Task.WhenAll(livePriceTask, macroTask); - var (livePrice, liveBid, liveAsk, preChange) = await livePriceTask; // Task-Result direkt nutzen + var (livePrice, liveBid, liveAsk, preChange) = await livePriceTask; var (vix, gspc, dxy) = await macroTask; ApplyLivePriceToCandles(cleanIsin, candles, querySymbol, currency, livePrice, liveBid, liveAsk); @@ -194,11 +191,9 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService { if (!livePrice.HasValue || livePrice.Value <= 0m) return; - // Währungsschutz: Trade Republic liefert IMMER EUR. - // Wenn die Kerzenhistorie USD ist (z.B. AAPL), darf der EUR-Livepreis NICHT direkt injiziert werden! if (candleCurrency.Equals("USD", StringComparison.OrdinalIgnoreCase) && !cleanIsin.StartsWith("DE") && !cleanIsin.StartsWith("AT")) { - _logger.LogDebug("[{Channel}] Skipping direct EUR live price injection for USD asset {Isin}", "TechnicalAnalysisChannel", cleanIsin); + _ = _finlyticLogger.LogDebugAsync(SettingKeys.TechnicalAnalysisChannel, "[TechnicalAnalysisDbService] Skipping direct EUR live price injection for USD asset {Isin}", cleanIsin); return; } @@ -266,7 +261,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Real-time price fetch skipped for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalAnalysisDbService] Real-time price fetch skipped for ISIN {Isin}", cleanIsin); } return (livePrice, liveBid, liveAsk, preChange); @@ -330,14 +325,14 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService !string.Equals(requestedTicker.Trim(), cleanIsin, StringComparison.OrdinalIgnoreCase) && !string.Equals(cached.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) { - return null; // Ticker mismatch, force refresh required + return null; } return JsonSerializer.Deserialize(cached.AnalysisJson); } } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Failed to read DB cache for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalAnalysisDbService] Failed to read DB cache for ISIN {Isin}", cleanIsin); } return null; @@ -374,7 +369,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService } catch (Exception ex) { - _logger.LogError(ex, "[{Channel}] Failed to persist TA DB cache for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + await _finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[TechnicalAnalysisDbService] Failed to persist TA DB cache for ISIN {Isin}", cleanIsin); } } diff --git a/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs b/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs index 22d9864..d06db83 100644 --- a/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs +++ b/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using FinlyticCore.Services; using FinlyticCore.Services.Yahoo; using FinlyticTechnicalAnalysis.Entities; -using Microsoft.Extensions.Logging; +using FinlyticTechnicalAnalysis.Util; +using Microsoft.Extensions.Configuration; namespace FinlyticTechnicalAnalysis.Services; @@ -40,17 +42,17 @@ public interface IYahooMarketDataScraper public class YahooMarketDataScraper : IYahooMarketDataScraper { private readonly YahooFinanceClient _yahooClient; - private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration; - private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IFinlyticLogger _finlyticLogger; public YahooMarketDataScraper( YahooFinanceClient yahooClient, - Microsoft.Extensions.Configuration.IConfiguration configuration, - ILogger logger) + IConfiguration configuration, + IFinlyticLogger finlyticLogger) { _yahooClient = yahooClient; _configuration = configuration; - _logger = logger; + _finlyticLogger = finlyticLogger; } /// @@ -66,7 +68,6 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper return cleanIsin; } - // Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017) if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase)) { var (cryptoSubtitle, cryptoName) = await FinlyticCore.Utils.CryptoSubtitleResolver.ResolveCryptoInfoAsync( @@ -82,8 +83,7 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper var res = await FetchHistoricalCandlesWithCurrencyAsync(candidate, "5d", "1d", cancellationToken); if (res.Candles.Count > 0) { - _logger.LogInformation("[{Channel}] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", - "TechnicalAnalysisChannel", cleanIsin, candidate, cryptoSubtitle); + await _finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", cleanIsin, candidate, cryptoSubtitle); return candidate; } } @@ -118,7 +118,7 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Failed to resolve Yahoo ticker for ISIN {Isin}", "TechnicalAnalysisChannel", cleanIsin); + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Failed to resolve Yahoo ticker for ISIN {Isin}", cleanIsin); } return null; @@ -150,11 +150,10 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper if (resultObj == null) { - _logger.LogWarning("[{Channel}] No chart data returned from Yahoo Client for symbol {Symbol}", "TechnicalAnalysisChannel", symbol); + await _finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[YahooMarketDataScraper] No chart data returned from Yahoo Client for symbol {Symbol}", symbol); return new YahooCandlesResult(results, detectedCurrency); } - // Extract currency metadata if (!string.IsNullOrWhiteSpace(resultObj.Meta?.Currency)) { detectedCurrency = resultObj.Meta.Currency.ToUpperInvariant(); @@ -184,7 +183,6 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper 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 @@ -200,12 +198,12 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper }); } - _logger.LogInformation("[{Channel}] Successfully fetched {Count} candles for {Symbol} ({Range}, {Interval}, Currency: {Currency})", - "TechnicalAnalysisChannel", results.Count, symbol, range, interval, detectedCurrency); + 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) { - _logger.LogError(ex, "[{Channel}] Error fetching historical candles for {Symbol}", "TechnicalAnalysisChannel", symbol); + await _finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[YahooMarketDataScraper] Error fetching historical candles for {Symbol}", symbol); } return new YahooCandlesResult(results, detectedCurrency); diff --git a/FinlyticTechnicalAnalysis/Util/SettingKeys.cs b/FinlyticTechnicalAnalysis/Util/SettingKeys.cs new file mode 100644 index 0000000..bfbfb8a --- /dev/null +++ b/FinlyticTechnicalAnalysis/Util/SettingKeys.cs @@ -0,0 +1,26 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticTechnicalAnalysis.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/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs b/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs index e67b438..50b7f7d 100644 --- a/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs +++ b/FinlyticTechnicalAnalysis/Util/TAMqttClient.cs @@ -1,10 +1,15 @@ using System; +using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Settings; using FinlyticCore.Models; +using FinlyticCore.Services; using FinlyticCore.Util; using FinlyticTechnicalAnalysis.Services; +using FinlyticTechnicalAnalysis.Util; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -12,19 +17,30 @@ using Microsoft.Extensions.Logging; namespace FinlyticTechnicalAnalysis.Util; -public class TAMqttClient( - ILogger logger, - IConfiguration configuration, - IServiceScopeFactory scopeFactory) : ManagedMqttClient(logger), IHostedService +public class TAMqttClient : ManagedMqttClient, IHostedService { + 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; + } + /// /// 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 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 { @@ -33,7 +49,7 @@ public class TAMqttClient( ClientId = clientId }; - logger.LogInformation("[{Channel}] Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", "TechnicalAnalysisChannel", config.Host, config.ClientId); + _logger.LogInformation("Starting Technical Analysis MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); await ConnectAsync(config); } @@ -42,17 +58,27 @@ public class TAMqttClient( /// public async Task StopAsync(CancellationToken cancellationToken) { - logger.LogInformation("[{Channel}] Stopping Technical Analysis MQTT client.", "TechnicalAnalysisChannel"); + _logger.LogInformation("Stopping Technical Analysis MQTT client."); await DisconnectAsync(); } protected override async Task OnConnectedAsync() { - logger.LogInformation("[{Channel}] Technical Analysis MQTT client connected. Subscribing to RPC topic...", "TechnicalAnalysisChannel"); + _logger.LogInformation("Technical Analysis MQTT client connected. Subscribing to RPC topics..."); await SubscribeAsync("services/request/ta_GetAnalysis/#"); await SubscribeAsync("services/request/tr_GetLivePrice/#"); + await SubscribeAsync("services/request/ta_settings_GetAll/#"); + await SubscribeAsync("services/request/ta_settings_Update/#"); await SubscribeAsync("services/request/health_Ping/#"); await SubscribeAsync("services/config/updated/#"); + + FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticTechnicalAnalysis", StringComparison.OrdinalIgnoreCase)) + { + await PublishAsync("finlytic/logs/FinlyticTechnicalAnalysis", logDto); + } + }; } protected override async Task OnMessageReceivedAsync(string topic, string payload) @@ -69,21 +95,96 @@ public class TAMqttClient( if (segments.Length < 4) return; var channel = segments[2]; - var correlationId = segments[segments.Length - 1]; + var correlationId = segments[^1]; - if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + switch (channel) { - await HandleHealthPingAsync(topic, segments, correlationId); - return; - } + case "ta_GetAnalysis": + await HandleGetAnalysisAsync(payload, correlationId); + break; - if (channel == "ta_GetAnalysis") - { - await HandleGetAnalysisAsync(payload, correlationId); + case "tr_GetLivePrice": + await HandleGetLivePriceAsync(payload, correlationId); + break; + + case "ta_settings_GetAll": + await HandleSettingsGetAllAsync(correlationId); + break; + + case "ta_settings_Update": + await HandleSettingsUpdateAsync(payload, correlationId); + break; + + case "health_Ping": + await HandleHealthPingAsync(topic, segments, correlationId); + break; + + default: + _logger.LogDebug("Received unhandled RPC channel: {Channel}", channel); + break; } - else if (channel == "tr_GetLivePrice") + } + + private async Task HandleSettingsGetAllAsync(string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicalAnalysis] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId); + try { - await HandleGetLivePriceAsync(payload, correlationId); + var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); + var responseTopic = $"services/response/ta_settings_GetAll/{correlationId}"; + + await PublishAsync(responseTopic, settings); + await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicalAnalysis] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic); + } + catch (Exception ex) + { + await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticTechnicalAnalysis] [Settings_GetAll] Failed to retrieve settings."); + } + } + + private async Task HandleSettingsUpdateAsync(string payload, string correlationId) + { + if (string.IsNullOrWhiteSpace(payload)) return; + + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicalAnalysis] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId); + try + { + Dictionary? updates = null; + try + { + updates = JsonSerializer.Deserialize>(payload); + } + catch + { + var list = JsonSerializer.Deserialize>(payload); + if (list != null) + { + updates = new Dictionary(); + foreach (var item in list) updates[item.Key] = item.Value; + } + } + + if (updates != null && updates.Count > 0) + { + await settingsService.UpdateSettingsAsync(updates); + await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTechnicalAnalysis] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count); + } + + var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); + var responseTopic = $"services/response/ta_settings_Update/{correlationId}"; + await PublishAsync(responseTopic, currentSettings); + } + catch (Exception ex) + { + await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticTechnicalAnalysis] [Settings_Update] Failed to update settings."); } } @@ -92,22 +193,21 @@ public class TAMqttClient( 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 doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("settings", out var settingsProp)) { - 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); + var dict = JsonSerializer.Deserialize>(settingsProp.GetRawText()); + if (dict != null && dict.Count > 0) + { + using var scope = _scopeFactory.CreateScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + await settings.UpdateSettingsAsync(dict); + } } } - catch (Exception ex) - { - logger.LogError(ex, "[{Channel}] [TAMqttClient] Error processing MQTT config update event.", "TechnicalAnalysisChannel"); - } + catch { } } private async Task HandleHealthPingAsync(string topic, string[] segments, string correlationId) @@ -119,40 +219,42 @@ public class TAMqttClient( 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); + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticTechnicalAnalysis", "Online", DateTime.UtcNow, "Connected")); + + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticTechnicalAnalysis] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId); } } private async Task HandleGetAnalysisAsync(string payload, string correlationId) { - logger.LogInformation("[{Channel}] Received RPC ta_GetAnalysis request. CorrelationId: {CorrelationId}", "TechnicalAnalysisChannel", correlationId); + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + + await finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[FinlyticTechnicalAnalysis] Received RPC ta_GetAnalysis request. CorrelationId: {CorrelationId}", 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 finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[FinlyticTechnicalAnalysis] Request missing mandatory ISIN parameter."); 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, req.Ticker); - logger.LogInformation("[{Channel}] Publishing RPC response to {ResponseTopic}", "TechnicalAnalysisChannel", responseTopic); + await finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[FinlyticTechnicalAnalysis] Publishing RPC response to {ResponseTopic}", 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 + await finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[FinlyticTechnicalAnalysis] Failed to fetch technical analysis for ISIN {Isin}", req.Isin); try { await PublishAsync(responseTopic, null); @@ -163,32 +265,32 @@ public class TAMqttClient( private async Task HandleGetLivePriceAsync(string payload, string correlationId) { - logger.LogInformation("[{Channel}] Received RPC tr_GetLivePrice request. CorrelationId: {CorrelationId}", "TechnicalAnalysisChannel", correlationId); + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + + await finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[FinlyticTechnicalAnalysis] Received RPC tr_GetLivePrice request. CorrelationId: {CorrelationId}", 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 finlyticLogger.LogWarningAsync(SettingKeys.TechnicalAnalysisChannel, "[FinlyticTechnicalAnalysis] tr_GetLivePrice request missing mandatory ISIN parameter."); 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 finlyticLogger.LogInfoAsync(SettingKeys.TechnicalAnalysisChannel, "[FinlyticTechnicalAnalysis] Publishing RPC response to {ResponseTopic} for ISIN {Isin}", 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); - + await finlyticLogger.LogErrorAsync(SettingKeys.TechnicalAnalysisChannel, ex, "[FinlyticTechnicalAnalysis] Failed to fetch live price for ISIN {Isin}", req.Isin); try { await PublishAsync(responseTopic, null);