diff --git a/FinlyticTrades/Database/TradesDbContext.cs b/FinlyticTrades/Database/TradesDbContext.cs index d5988c9..780158e 100644 --- a/FinlyticTrades/Database/TradesDbContext.cs +++ b/FinlyticTrades/Database/TradesDbContext.cs @@ -1,10 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FinlyticCore.Database; using FinlyticCore.Entities.Settings; using FinlyticTrades.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; namespace FinlyticTrades.Database; -public class TradesDbContext : DbContext +public class TradesDbContext : DbContext, ISettingsDbContext { public TradesDbContext(DbContextOptions options) : base(options) { } @@ -20,7 +25,7 @@ public class TradesDbContext : DbContext modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); - entity.HasIndex(e => e.Key); + entity.HasIndex(e => e.Key).IsUnique(); }); var stringListConverter = @@ -57,3 +62,13 @@ public class TradesDbContext : DbContext }); } } + +public class TradesDbContextFactory : IDesignTimeDbContextFactory +{ + public TradesDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=trades;Username=postgres;Password=postgres"); + return new TradesDbContext(optionsBuilder.Options); + } +} diff --git a/FinlyticTrades/Migrations/20260815184034_AddDynamicSettings.Designer.cs b/FinlyticTrades/Migrations/20260815184034_AddDynamicSettings.Designer.cs new file mode 100644 index 0000000..9fdfd2b --- /dev/null +++ b/FinlyticTrades/Migrations/20260815184034_AddDynamicSettings.Designer.cs @@ -0,0 +1,358 @@ +// +using System; +using FinlyticTrades.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 FinlyticTrades.Migrations +{ + [DbContext(typeof(TradesDbContext))] + [Migration("20260815184034_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("FinlyticTrades.Entities.TradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualEntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CloseReason") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivativeIsin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DerivativeProductCategories") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntryFee") + .HasColumnType("decimal(18,4)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EntryZoneMax") + .HasColumnType("decimal(18,4)"); + + b.Property("EntryZoneMin") + .HasColumnType("decimal(18,4)"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ExecutionTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ExitFee") + .HasColumnType("decimal(18,4)"); + + b.Property("FundamentalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasCfd") + .HasColumnType("boolean"); + + b.Property("InstrumentType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("IsGlobalProposal") + .HasColumnType("boolean"); + + b.Property("IsRecurring") + .HasColumnType("boolean"); + + b.Property("IsWin") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("KnockoutThreshold") + .HasColumnType("decimal(18,4)"); + + b.Property("LeverageUsed") + .HasColumnType("decimal(18,4)"); + + b.Property("MaxLeverage") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlAbsolute") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlPercent") + .HasColumnType("decimal(18,4)"); + + b.Property("PositionSize") + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("Reasoning") + .IsRequired() + .HasColumnType("text"); + + b.Property("RiskRewardRatio") + .HasColumnType("decimal(18,4)"); + + b.Property("RiskTolerance") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RiskWarning") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignalType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StopLoss") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TakeProfit") + .HasColumnType("decimal(18,4)"); + + b.Property("TakeProfitTargets") + .HasColumnType("text"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TtlMinutes") + .HasColumnType("integer"); + + b.Property("UserExitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("UserExitTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("decimal(18,4)"); + + b.Property("WinRate") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("AnalysisId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EventId"); + + b.HasIndex("Isin"); + + b.HasIndex("Sector"); + + b.HasIndex("Status"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("trades"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeHourlyUpdateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("FloatingPnlPercent") + .HasColumnType("decimal(18,4)"); + + b.Property("Reasoning") + .IsRequired() + .HasColumnType("text"); + + b.Property("Recommendation") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SuggestedStopLoss") + .HasColumnType("decimal(18,4)"); + + b.Property("SuggestedTakeProfit") + .HasColumnType("decimal(18,4)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TradeId") + .HasColumnType("uuid"); + + b.Property("VixValue") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TradeId"); + + b.HasIndex("TradeId", "Timestamp"); + + b.ToTable("trade_hourly_updates"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradesSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AtrStopLossMultiplier") + .HasColumnType("double precision"); + + b.Property("MaxOpenPositions") + .HasColumnType("integer"); + + b.Property("RiskPerTradePercentage") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeHourlyUpdateEntity", b => + { + b.HasOne("FinlyticTrades.Entities.TradeEntity", "Trade") + .WithMany("HourlyUpdates") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trade"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b => + { + b.Navigation("HourlyUpdates"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTrades/Migrations/20260815184034_AddDynamicSettings.cs b/FinlyticTrades/Migrations/20260815184034_AddDynamicSettings.cs new file mode 100644 index 0000000..0f3b142 --- /dev/null +++ b/FinlyticTrades/Migrations/20260815184034_AddDynamicSettings.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class AddDynamicSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings"); + + migrationBuilder.CreateIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings"); + + migrationBuilder.CreateIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings", + column: "Key"); + } + } +} diff --git a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs index bb4e6aa..2c9b43e 100644 --- a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs +++ b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs @@ -47,7 +47,8 @@ namespace FinlyticTrades.Migrations b.HasKey("Id"); - b.HasIndex("Key"); + b.HasIndex("Key") + .IsUnique(); b.ToTable("DynamicSettings"); }); diff --git a/FinlyticTrades/Program.cs b/FinlyticTrades/Program.cs index b44e3c0..eaf8c2a 100644 --- a/FinlyticTrades/Program.cs +++ b/FinlyticTrades/Program.cs @@ -1,5 +1,6 @@ using System; -using FinlyticCore.Models.Trades; +using FinlyticCore.Database; +using FinlyticCore.Services; using FinlyticTrades.Database; using FinlyticTrades.Services; using FinlyticTrades.Util; @@ -7,19 +8,23 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; var builder = Host.CreateApplicationBuilder(args); // 1. Standard DbContext (Scoped) builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(sp => sp.GetRequiredService()); -// 2. Domain Services (Scoped) +// 2. Core Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); + +// 3. Domain Services (Scoped) builder.Services.AddScoped(); builder.Services.AddScoped(); -// 3. Hosted Services / Singletons +// 4. Hosted Services / Singletons builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); @@ -34,14 +39,10 @@ using (var scope = host.Services.CreateScope()) var context = scope.ServiceProvider.GetRequiredService(); await context.Database.MigrateAsync(); Console.WriteLine("Database migrations successfully executed for FinlyticTrades."); - - var settingsService = scope.ServiceProvider.GetRequiredService(); - await settingsService.GetSettingsAsync(); } catch (Exception ex) { - var logger = scope.ServiceProvider.GetRequiredService>(); - logger.LogError(ex, "An error occurred during database migration for FinlyticTrades on startup."); + Console.WriteLine($"Critical error during database migration for FinlyticTrades: {ex.Message}"); } } diff --git a/FinlyticTrades/Services/FeedbackExporterEngine.cs b/FinlyticTrades/Services/FeedbackExporterEngine.cs index 1417866..1201cfe 100644 --- a/FinlyticTrades/Services/FeedbackExporterEngine.cs +++ b/FinlyticTrades/Services/FeedbackExporterEngine.cs @@ -7,12 +7,13 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Models.Trades; +using FinlyticCore.Services; using FinlyticTrades.Database; using FinlyticTrades.Entities; +using FinlyticTrades.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Parquet.Serialization; namespace FinlyticTrades.Services; @@ -25,17 +26,16 @@ public interface IFeedbackExporterEngine Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default); } - public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine { private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; private readonly string _feedbackDir; - public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, ILogger logger) + public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, IFinlyticLogger finlyticLogger) { _scopeFactory = scopeFactory; - _logger = logger; + _finlyticLogger = finlyticLogger; _feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback"); if (!Directory.Exists(_feedbackDir)) @@ -46,7 +46,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("[{Channel}] Feedback Exporter Engine background service started.", "TradesChannel"); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[FeedbackExporterEngine] Feedback Exporter Engine background service started."); try { @@ -69,7 +69,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine } catch (Exception ex) { - _logger.LogError(ex, "[{Channel}] Error executing feedback exporter job.", "TradesChannel"); + await _finlyticLogger.LogErrorAsync(SettingKeys.TradesChannel, ex, "[FeedbackExporterEngine] Error executing feedback exporter job."); } try @@ -82,7 +82,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine } } - _logger.LogInformation("[{Channel}] Feedback Exporter Engine background service stopped.", "TradesChannel"); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[FeedbackExporterEngine] Feedback Exporter Engine background service stopped."); } /// @@ -101,7 +101,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine if (closedTrades.Count == 0) { - _logger.LogInformation("[{Channel}] No closed trades available for export.", "TradesChannel"); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[FeedbackExporterEngine] No closed trades available for export."); return; } @@ -184,16 +184,16 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine File.Move(parquetTmpPath, parquetPath, overwrite: true); - _logger.LogInformation("[{Channel}] Exported Parquet feedback file for sector '{Sector}' to {ParquetPath}", "TradesChannel", sectorName, parquetPath); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[FeedbackExporterEngine] Exported Parquet feedback file for sector '{Sector}' to {ParquetPath}", sectorName, parquetPath); } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Failed to write Parquet file for sector '{Sector}'. JSON file was written successfully.", "TradesChannel", sectorName); + await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, ex, "[FeedbackExporterEngine] Failed to write Parquet file for sector '{Sector}'. JSON file was written successfully.", sectorName); } } - _logger.LogInformation("[{Channel}] Successfully exported feedback data for {Count} closed trades across {Sectors} sectors.", - "TradesChannel", closedTrades.Count, groups.Count()); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[FeedbackExporterEngine] Successfully exported feedback data for {Count} closed trades across {Sectors} sectors.", + closedTrades.Count, groups.Count()); } private static string SanitizeSectorName(string? sector) diff --git a/FinlyticTrades/Services/TradeLifecycleService.cs b/FinlyticTrades/Services/TradeLifecycleService.cs index 12134ce..088b4ae 100644 --- a/FinlyticTrades/Services/TradeLifecycleService.cs +++ b/FinlyticTrades/Services/TradeLifecycleService.cs @@ -5,10 +5,11 @@ using System.Threading; using System.Threading.Tasks; using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Trades; +using FinlyticCore.Services; using FinlyticTrades.Database; using FinlyticTrades.Entities; +using FinlyticTrades.Util; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace FinlyticTrades.Services; @@ -28,19 +29,19 @@ public interface ITradeLifecycleService public class TradeLifecycleService : ITradeLifecycleService { private readonly TradesDbContext _dbContext; - private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; - public TradeLifecycleService(TradesDbContext dbContext, ILogger logger) + public TradeLifecycleService(TradesDbContext dbContext, IFinlyticLogger finlyticLogger) { _dbContext = dbContext; - _logger = logger; + _finlyticLogger = finlyticLogger; } public async Task ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default) { if (response == null || !response.IsTradeProposed) { - _logger.LogInformation("[{Channel}] Manual analysis response indicated NO trade proposed (AnalysisId: {AnalysisId}). Skipping.", "TradesChannel", response?.AnalysisId); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Manual analysis response indicated NO trade proposed (AnalysisId: {AnalysisId}). Skipping.", response?.AnalysisId); return false; } @@ -90,7 +91,7 @@ public class TradeLifecycleService : ITradeLifecycleService { if (string.IsNullOrWhiteSpace(proposal.Symbol) && string.IsNullOrWhiteSpace(proposal.Isin)) { - _logger.LogWarning("[{Channel}] ProcessProposedTradeAsync: Received proposal with missing Symbol and ISIN. Skipping.", "TradesChannel"); + await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] ProcessProposedTradeAsync: Received proposal with missing Symbol and ISIN. Skipping."); return false; } @@ -109,8 +110,8 @@ public class TradeLifecycleService : ITradeLifecycleService { if (existingTrade.Status == TradeStatus.Active) { - _logger.LogInformation("[{Channel}] An ACTIVE trade {TradeId} already exists for {Symbol} ({Isin}). Skipping duplicate proposed trade creation.", - "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] An ACTIVE trade {TradeId} already exists for {Symbol} ({Isin}). Skipping duplicate proposed trade creation.", + existingTrade.TradeId, proposal.Symbol, proposal.Isin); return true; } @@ -123,8 +124,8 @@ public class TradeLifecycleService : ITradeLifecycleService _dbContext.Trades.Update(existingTrade); await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Successfully UPDATED existing trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", - "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully UPDATED existing trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", + existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status); return true; } @@ -143,8 +144,8 @@ public class TradeLifecycleService : ITradeLifecycleService _dbContext.Trades.Add(tradeEntity); await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Successfully ingested NEW trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", - "TradesChannel", tradeId, proposal.Symbol, proposal.Isin, targetStatus); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully ingested NEW trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", + tradeId, proposal.Symbol, proposal.Isin, targetStatus); return true; } @@ -162,7 +163,7 @@ public class TradeLifecycleService : ITradeLifecycleService { if (existingTrade.Status == TradeStatus.Closed) { - _logger.LogWarning("[{Channel}] Refused to accept trade {TradeId} because its status is CLOSED", "TradesChannel", existingTrade.TradeId); + await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Refused to accept trade {TradeId} because its status is CLOSED", existingTrade.TradeId); return null; } @@ -195,7 +196,7 @@ public class TradeLifecycleService : ITradeLifecycleService _dbContext.Trades.Update(existingTrade); await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Successfully ACCEPTED and UPDATED trade {TradeId} for ISIN {Isin}, UserId: {UserId}", "TradesChannel", existingTrade.TradeId, existingTrade.Isin, existingTrade.UserId); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully ACCEPTED and UPDATED trade {TradeId} for ISIN {Isin}, UserId: {UserId}", existingTrade.TradeId, existingTrade.Isin, existingTrade.UserId); return existingTrade; } @@ -261,7 +262,7 @@ public class TradeLifecycleService : ITradeLifecycleService _dbContext.Trades.Add(newTrade); await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Successfully created active trade {TradeId} for ISIN {Isin}, UserId: {UserId}", "TradesChannel", newTrade.TradeId, request.Isin, newTrade.UserId); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully created active trade {TradeId} for ISIN {Isin}, UserId: {UserId}", newTrade.TradeId, request.Isin, newTrade.UserId); return newTrade; } @@ -272,7 +273,7 @@ public class TradeLifecycleService : ITradeLifecycleService if (trade == null || (trade.Status != TradeStatus.Active && trade.Status != TradeStatus.Proposed)) { - _logger.LogWarning("[{Channel}] Cannot add hourly update: Trade {TradeId} not found or not active/proposed.", "TradesChannel", update.TradeId); + await _finlyticLogger.LogWarningAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Cannot add hourly update: Trade {TradeId} not found or not active/proposed.", update.TradeId); return; } @@ -305,16 +306,14 @@ public class TradeLifecycleService : ITradeLifecycleService } else { - // NO AUTO CLOSE for active user trades! - // Trade remains Active, alert is stored in HourlyUpdates and surfaced in UI for manual confirmation. - _logger.LogInformation("[{Channel}] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.", - "TradesChannel", trade.TradeId, update.Reasoning); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.", + trade.TradeId, update.Reasoning); } } await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}", - "TradesChannel", update.TradeId, update.Recommendation, update.CurrentPrice); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}", + update.TradeId, update.Recommendation, update.CurrentPrice); } public async Task> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default) @@ -374,8 +373,8 @@ public class TradeLifecycleService : ITradeLifecycleService CalculatePnL(trade); await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Trade {TradeId} manually closed at price {ExitPrice}. PnL: {PnlAbs} ({PnlPct:F2}%)", - "TradesChannel", trade.TradeId, trade.UserExitPrice, trade.PnlAbsolute, trade.PnlPercent); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Trade {TradeId} manually closed at price {ExitPrice}. PnL: {PnlAbs} ({PnlPct:F2}%)", + trade.TradeId, trade.UserExitPrice, trade.PnlAbsolute, trade.PnlPercent); return trade; } @@ -392,7 +391,7 @@ public class TradeLifecycleService : ITradeLifecycleService trade.ClosedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Trade {TradeId} rejected by user.", "TradesChannel", trade.TradeId); + await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Trade {TradeId} rejected by user.", trade.TradeId); return trade; } diff --git a/FinlyticTrades/Util/SettingKeys.cs b/FinlyticTrades/Util/SettingKeys.cs new file mode 100644 index 0000000..0d99cf4 --- /dev/null +++ b/FinlyticTrades/Util/SettingKeys.cs @@ -0,0 +1,22 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticTrades.Util; + +public static class SettingKeys +{ + // --- Logging-Kanäle --- + public static readonly SettingKey TradesChannel = new("Logging.Channel.Trades", true); + public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); + public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); + + // --- Trade Management & Limits --- + public static readonly SettingKey MaxActiveTradesCount = new("Trades.MaxActiveTradesCount", 20); + public static readonly SettingKey AutoArchiveClosedTradesDays = new("Trades.AutoArchiveClosedTradesDays", 30); + public static readonly SettingKey DefaultSlippageTolerancePercent = new("Trades.DefaultSlippageTolerancePercent", 0.5); + public static readonly SettingKey ProposedTradeExpirationHours = new("Trades.ProposedTradeExpirationHours", 24); + + // --- Parquet / Data Export --- + public static readonly SettingKey EnableParquetExport = new("Export.EnableParquetExport", true); + public static readonly SettingKey ParquetExportIntervalHours = new("Export.ParquetExportIntervalHours", 6); + public static readonly SettingKey ParquetExportDirectory = new("Export.ParquetExportDirectory", "data/exports/trades"); +} diff --git a/FinlyticTrades/Util/TradesMqttClient.cs b/FinlyticTrades/Util/TradesMqttClient.cs index cd3a93b..4548f30 100644 --- a/FinlyticTrades/Util/TradesMqttClient.cs +++ b/FinlyticTrades/Util/TradesMqttClient.cs @@ -6,9 +6,11 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Settings; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models; using FinlyticCore.Models.Trades; +using FinlyticCore.Services; using FinlyticCore.Util; using FinlyticTrades.Entities; using FinlyticTrades.Services; @@ -46,19 +48,19 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_trades")}_{Guid.NewGuid():N}" }; - _logger.LogInformation("[{Channel}] Starting Unified Trades MQTT Client. Host: {Host}, ClientId: {ClientId}", "TradesChannel", config.Host, config.ClientId); + _logger.LogInformation("Starting Unified Trades MQTT Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); await ConnectAsync(config); } public async Task StopAsync(CancellationToken cancellationToken) { - _logger.LogInformation("[{Channel}] Stopping Unified Trades MQTT Client.", "TradesChannel"); + _logger.LogInformation("Stopping Unified Trades MQTT Client."); await DisconnectAsync(); } protected override async Task OnConnectedAsync() { - _logger.LogInformation("[{Channel}] Trades MQTT Client connected. Subscribing to topics...", "TradesChannel"); + _logger.LogInformation("Trades MQTT Client connected. Subscribing to topics..."); await SubscribeAsync("finlytic/trades/proposed/#"); await SubscribeAsync("finlytic/trades/updates/#"); @@ -67,11 +69,21 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService await SubscribeAsync("services/request/trades_Close/#"); await SubscribeAsync("services/request/trades_Reject/#"); await SubscribeAsync("services/request/trades_Accept/#"); + await SubscribeAsync("services/request/trades_settings_GetAll/#"); + await SubscribeAsync("services/request/trades_settings_Update/#"); await SubscribeAsync("services/config/updated/#"); await SubscribeAsync("services/request/health_Ping/#"); await SubscribeAsync("services/response/tr_GetLivePrice/#"); - _logger.LogInformation("[{Channel}] Successfully subscribed to all event and RPC channels.", "TradesChannel"); + FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticTrades", StringComparison.OrdinalIgnoreCase)) + { + await PublishAsync("finlytic/logs/FinlyticTrades", logDto); + } + }; + + _logger.LogInformation("Successfully subscribed to all event and RPC channels."); } protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) @@ -91,7 +103,9 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService string respTopic = $"services/response/health_Ping/{correlationId}"; var healthResp = new ServiceHealthResponse("FinlyticTrades", "Online", DateTime.UtcNow, "Connected"); await PublishAsync(respTopic, healthResp); - _logger.LogInformation("[{Channel}] [TradesMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "TradesChannel", correlationId); + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[TradesMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId); } return; } @@ -100,22 +114,35 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService { if (topic.EndsWith("FinlyticTrades", StringComparison.OrdinalIgnoreCase)) { - _logger.LogInformation("[{Channel}] [TradesMqttClient] Received config update event for FinlyticTrades.", "TradesChannel"); var payload = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload); if (payload?.Settings != null && payload.Settings.Count > 0) { using var scope = _scopeFactory.CreateScope(); - var settingsDb = scope.ServiceProvider.GetRequiredService(); - await settingsDb.UpdateSettingsFromDictionaryAsync(payload.Settings); - _logger.LogInformation("[{Channel}] [TradesMqttClient] Persisted {Count} updated settings to FinlyticTrades database.", "TradesChannel", payload.Settings.Count); + var settings = scope.ServiceProvider.GetRequiredService(); + var dict = payload.Settings.ToDictionary(k => k.Key, v => (object?)v.Value); + await settings.UpdateSettingsAsync(dict); } } return; } - // Für Scoped-Services erzeugen wir pro eingehender Nachricht einen eigenen Scope + if (topic.StartsWith("services/request/trades_settings_GetAll", StringComparison.OrdinalIgnoreCase)) + { + var correlationId = topic.Split('/').Last(); + await HandleSettingsGetAllAsync(correlationId); + return; + } + + if (topic.StartsWith("services/request/trades_settings_Update", StringComparison.OrdinalIgnoreCase)) + { + var correlationId = topic.Split('/').Last(); + await HandleSettingsUpdateAsync(payloadStr, correlationId); + return; + } + using var msgScope = _scopeFactory.CreateScope(); var tradeLifecycleService = msgScope.ServiceProvider.GetRequiredService(); + var finlyticLoggerInstance = msgScope.ServiceProvider.GetRequiredService>(); if (topic.StartsWith("finlytic/trades/proposed/")) { @@ -126,7 +153,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService } else { - _logger.LogWarning("[{Channel}] [TradesMqttClient] Received proposed trade payload but Symbol/ISIN is empty. Skipping ingestion.", "TradesChannel"); + await finlyticLoggerInstance.LogWarningAsync(SettingKeys.TradesChannel, "[TradesMqttClient] Received proposed trade payload but Symbol/ISIN is empty. Skipping ingestion."); } } else if (topic.StartsWith("finlytic/trades/accept/")) @@ -199,7 +226,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService } catch (Exception ex) { - _logger.LogDebug(ex, "[{Channel}] Live price fetch skipped or timed out during trades_Get", "TradesChannel"); + await finlyticLoggerInstance.LogDebugAsync(SettingKeys.TradesChannel, "[TradesMqttClient] Live price fetch skipped or timed out during trades_Get: {Message}", ex.Message); } } @@ -250,7 +277,72 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService } catch (Exception ex) { - _logger.LogError(ex, "[{Channel}] Error processing incoming MQTT message on topic {Topic}", "TradesChannel", topic); + using var scope = _scopeFactory.CreateScope(); + var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); + await finlyticLogger.LogErrorAsync(SettingKeys.TradesChannel, ex, "[TradesMqttClient] Error processing incoming MQTT message on topic {Topic}", topic); + } + } + + 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, "[FinlyticTrades] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId); + try + { + var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); + var responseTopic = $"services/response/trades_settings_GetAll/{correlationId}"; + + await PublishAsync(responseTopic, settings); + await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTrades] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic); + } + catch (Exception ex) + { + await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticTrades] [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, "[FinlyticTrades] [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, "[FinlyticTrades] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count); + } + + var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); + var responseTopic = $"services/response/trades_settings_Update/{correlationId}"; + await PublishAsync(responseTopic, currentSettings); + } + catch (Exception ex) + { + await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticTrades] [Settings_Update] Failed to update settings."); } }