feat(trades): dynamic settings, IFinlyticLogger, live log streaming, and EF migration

This commit is contained in:
2026-08-15 21:30:19 +02:00
parent 0d370d09e7
commit 57554a9582
9 changed files with 588 additions and 63 deletions
+17 -2
View File
@@ -1,10 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticCore.Database;
using FinlyticCore.Entities.Settings; using FinlyticCore.Entities.Settings;
using FinlyticTrades.Entities; using FinlyticTrades.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticTrades.Database; namespace FinlyticTrades.Database;
public class TradesDbContext : DbContext public class TradesDbContext : DbContext, ISettingsDbContext
{ {
public TradesDbContext(DbContextOptions<TradesDbContext> options) : base(options) { } public TradesDbContext(DbContextOptions<TradesDbContext> options) : base(options) { }
@@ -20,7 +25,7 @@ public class TradesDbContext : DbContext
modelBuilder.Entity<SettingEntity>(entity => modelBuilder.Entity<SettingEntity>(entity =>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key); entity.HasIndex(e => e.Key).IsUnique();
}); });
var stringListConverter = var stringListConverter =
@@ -57,3 +62,13 @@ public class TradesDbContext : DbContext
}); });
} }
} }
public class TradesDbContextFactory : IDesignTimeDbContextFactory<TradesDbContext>
{
public TradesDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<TradesDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=trades;Username=postgres;Password=postgres");
return new TradesDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,358 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("ActualEntryPrice")
.HasColumnType("decimal(18,4)");
b.Property<string>("AnalysisId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("CloseReason")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DerivativeIsin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("EntryFee")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("EntryPrice")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("EntryZoneMax")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("EntryZoneMin")
.HasColumnType("decimal(18,4)");
b.Property<string>("EventId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("ExecutionTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("ExitFee")
.HasColumnType("decimal(18,4)");
b.Property<string>("FundamentalRationale")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("InstrumentType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<bool>("IsGlobalProposal")
.HasColumnType("boolean");
b.Property<bool>("IsRecurring")
.HasColumnType("boolean");
b.Property<bool?>("IsWin")
.HasColumnType("boolean");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal?>("KnockoutThreshold")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("LeverageUsed")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("MaxLeverage")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PnlAbsolute")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PnlPercent")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PositionSize")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<string>("Reasoning")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("RiskRewardRatio")
.HasColumnType("decimal(18,4)");
b.Property<string>("RiskTolerance")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("RiskWarning")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Sector")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SignalType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<decimal>("StopLoss")
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal>("TakeProfit")
.HasColumnType("decimal(18,4)");
b.Property<string>("TakeProfitTargets")
.HasColumnType("text");
b.Property<string>("TechnicalRationale")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Timeframe")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TradeId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("TtlMinutes")
.HasColumnType("integer");
b.Property<decimal?>("UserExitPrice")
.HasColumnType("decimal(18,4)");
b.Property<DateTime?>("UserExitTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("VixRegime")
.HasColumnType("integer");
b.Property<decimal>("VixValue")
.HasColumnType("decimal(18,4)");
b.Property<double>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("CurrentPrice")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("FloatingPnlPercent")
.HasColumnType("decimal(18,4)");
b.Property<string>("Reasoning")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Recommendation")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal?>("SuggestedStopLoss")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("SuggestedTakeProfit")
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TradeId")
.HasColumnType("uuid");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("AtrStopLossMultiplier")
.HasColumnType("double precision");
b.Property<int>("MaxOpenPositions")
.HasColumnType("integer");
b.Property<double>("RiskPerTradePercentage")
.HasColumnType("double precision");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticTrades.Migrations
{
/// <inheritdoc />
public partial class AddDynamicSettings : Migration
{
/// <inheritdoc />
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings");
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key");
}
}
}
@@ -47,7 +47,8 @@ namespace FinlyticTrades.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Key"); b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings"); b.ToTable("DynamicSettings");
}); });
+10 -9
View File
@@ -1,5 +1,6 @@
using System; using System;
using FinlyticCore.Models.Trades; using FinlyticCore.Database;
using FinlyticCore.Services;
using FinlyticTrades.Database; using FinlyticTrades.Database;
using FinlyticTrades.Services; using FinlyticTrades.Services;
using FinlyticTrades.Util; using FinlyticTrades.Util;
@@ -7,19 +8,23 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
// 1. Standard DbContext (Scoped) // 1. Standard DbContext (Scoped)
builder.Services.AddDbContext<TradesDbContext>(options => builder.Services.AddDbContext<TradesDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<TradesDbContext>());
// 2. Domain Services (Scoped) // 2. Core Services
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
// 3. Domain Services (Scoped)
builder.Services.AddScoped<ITradeLifecycleService, TradeLifecycleService>(); builder.Services.AddScoped<ITradeLifecycleService, TradeLifecycleService>();
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>(); builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
// 3. Hosted Services / Singletons // 4. Hosted Services / Singletons
builder.Services.AddSingleton<TradesMqttClient>(); builder.Services.AddSingleton<TradesMqttClient>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TradesMqttClient>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<TradesMqttClient>());
builder.Services.AddHostedService<FeedbackExporterEngine>(); builder.Services.AddHostedService<FeedbackExporterEngine>();
@@ -34,14 +39,10 @@ using (var scope = host.Services.CreateScope())
var context = scope.ServiceProvider.GetRequiredService<TradesDbContext>(); var context = scope.ServiceProvider.GetRequiredService<TradesDbContext>();
await context.Database.MigrateAsync(); await context.Database.MigrateAsync();
Console.WriteLine("Database migrations successfully executed for FinlyticTrades."); Console.WriteLine("Database migrations successfully executed for FinlyticTrades.");
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
await settingsService.GetSettingsAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>(); Console.WriteLine($"Critical error during database migration for FinlyticTrades: {ex.Message}");
logger.LogError(ex, "An error occurred during database migration for FinlyticTrades on startup.");
} }
} }
@@ -7,12 +7,13 @@ using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using FinlyticTrades.Database; using FinlyticTrades.Database;
using FinlyticTrades.Entities; using FinlyticTrades.Entities;
using FinlyticTrades.Util;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Parquet.Serialization; using Parquet.Serialization;
namespace FinlyticTrades.Services; namespace FinlyticTrades.Services;
@@ -25,17 +26,16 @@ public interface IFeedbackExporterEngine
Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default); Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default);
} }
public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine
{ {
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FeedbackExporterEngine> _logger; private readonly IFinlyticLogger<FeedbackExporterEngine> _finlyticLogger;
private readonly string _feedbackDir; private readonly string _feedbackDir;
public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, ILogger<FeedbackExporterEngine> logger) public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, IFinlyticLogger<FeedbackExporterEngine> finlyticLogger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger; _finlyticLogger = finlyticLogger;
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback"); _feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
if (!Directory.Exists(_feedbackDir)) if (!Directory.Exists(_feedbackDir))
@@ -46,7 +46,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine
protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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 try
{ {
@@ -69,7 +69,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine
} }
catch (Exception ex) 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 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.");
} }
/// <summary> /// <summary>
@@ -101,7 +101,7 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine
if (closedTrades.Count == 0) 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; return;
} }
@@ -184,16 +184,16 @@ public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine
File.Move(parquetTmpPath, parquetPath, overwrite: true); 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) 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.", await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[FeedbackExporterEngine] Successfully exported feedback data for {Count} closed trades across {Sectors} sectors.",
"TradesChannel", closedTrades.Count, groups.Count()); closedTrades.Count, groups.Count());
} }
private static string SanitizeSectorName(string? sector) private static string SanitizeSectorName(string? sector)
@@ -5,10 +5,11 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using FinlyticTrades.Database; using FinlyticTrades.Database;
using FinlyticTrades.Entities; using FinlyticTrades.Entities;
using FinlyticTrades.Util;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace FinlyticTrades.Services; namespace FinlyticTrades.Services;
@@ -28,19 +29,19 @@ public interface ITradeLifecycleService
public class TradeLifecycleService : ITradeLifecycleService public class TradeLifecycleService : ITradeLifecycleService
{ {
private readonly TradesDbContext _dbContext; private readonly TradesDbContext _dbContext;
private readonly ILogger<TradeLifecycleService> _logger; private readonly IFinlyticLogger<TradeLifecycleService> _finlyticLogger;
public TradeLifecycleService(TradesDbContext dbContext, ILogger<TradeLifecycleService> logger) public TradeLifecycleService(TradesDbContext dbContext, IFinlyticLogger<TradeLifecycleService> finlyticLogger)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _finlyticLogger = finlyticLogger;
} }
public async Task<bool> ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default) public async Task<bool> ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default)
{ {
if (response == null || !response.IsTradeProposed) 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; return false;
} }
@@ -90,7 +91,7 @@ public class TradeLifecycleService : ITradeLifecycleService
{ {
if (string.IsNullOrWhiteSpace(proposal.Symbol) && string.IsNullOrWhiteSpace(proposal.Isin)) 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; return false;
} }
@@ -109,8 +110,8 @@ public class TradeLifecycleService : ITradeLifecycleService
{ {
if (existingTrade.Status == TradeStatus.Active) if (existingTrade.Status == TradeStatus.Active)
{ {
_logger.LogInformation("[{Channel}] An ACTIVE trade {TradeId} already exists for {Symbol} ({Isin}). Skipping duplicate proposed trade creation.", await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] An ACTIVE trade {TradeId} already exists for {Symbol} ({Isin}). Skipping duplicate proposed trade creation.",
"TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin); existingTrade.TradeId, proposal.Symbol, proposal.Isin);
return true; return true;
} }
@@ -123,8 +124,8 @@ public class TradeLifecycleService : ITradeLifecycleService
_dbContext.Trades.Update(existingTrade); _dbContext.Trades.Update(existingTrade);
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Successfully UPDATED existing trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully UPDATED existing trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}",
"TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status); existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status);
return true; return true;
} }
@@ -143,8 +144,8 @@ public class TradeLifecycleService : ITradeLifecycleService
_dbContext.Trades.Add(tradeEntity); _dbContext.Trades.Add(tradeEntity);
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Successfully ingested NEW trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Successfully ingested NEW trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}",
"TradesChannel", tradeId, proposal.Symbol, proposal.Isin, targetStatus); tradeId, proposal.Symbol, proposal.Isin, targetStatus);
return true; return true;
} }
@@ -162,7 +163,7 @@ public class TradeLifecycleService : ITradeLifecycleService
{ {
if (existingTrade.Status == TradeStatus.Closed) 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; return null;
} }
@@ -195,7 +196,7 @@ public class TradeLifecycleService : ITradeLifecycleService
_dbContext.Trades.Update(existingTrade); _dbContext.Trades.Update(existingTrade);
await _dbContext.SaveChangesAsync(cancellationToken); 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; return existingTrade;
} }
@@ -261,7 +262,7 @@ public class TradeLifecycleService : ITradeLifecycleService
_dbContext.Trades.Add(newTrade); _dbContext.Trades.Add(newTrade);
await _dbContext.SaveChangesAsync(cancellationToken); 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; return newTrade;
} }
@@ -272,7 +273,7 @@ public class TradeLifecycleService : ITradeLifecycleService
if (trade == null || (trade.Status != TradeStatus.Active && trade.Status != TradeStatus.Proposed)) 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; return;
} }
@@ -305,16 +306,14 @@ public class TradeLifecycleService : ITradeLifecycleService
} }
else else
{ {
// NO AUTO CLOSE for active user trades! await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.",
// Trade remains Active, alert is stored in HourlyUpdates and surfaced in UI for manual confirmation. trade.TradeId, update.Reasoning);
_logger.LogInformation("[{Channel}] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.",
"TradesChannel", trade.TradeId, update.Reasoning);
} }
} }
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}", await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}",
"TradesChannel", update.TradeId, update.Recommendation, update.CurrentPrice); update.TradeId, update.Recommendation, update.CurrentPrice);
} }
public async Task<List<TradeEntity>> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default) public async Task<List<TradeEntity>> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default)
@@ -374,8 +373,8 @@ public class TradeLifecycleService : ITradeLifecycleService
CalculatePnL(trade); CalculatePnL(trade);
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Trade {TradeId} manually closed at price {ExitPrice}. PnL: {PnlAbs} ({PnlPct:F2}%)", await _finlyticLogger.LogInfoAsync(SettingKeys.TradesChannel, "[TradeLifecycleService] Trade {TradeId} manually closed at price {ExitPrice}. PnL: {PnlAbs} ({PnlPct:F2}%)",
"TradesChannel", trade.TradeId, trade.UserExitPrice, trade.PnlAbsolute, trade.PnlPercent); trade.TradeId, trade.UserExitPrice, trade.PnlAbsolute, trade.PnlPercent);
return trade; return trade;
} }
@@ -392,7 +391,7 @@ public class TradeLifecycleService : ITradeLifecycleService
trade.ClosedAt = DateTime.UtcNow; trade.ClosedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken); 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; return trade;
} }
+22
View File
@@ -0,0 +1,22 @@
using FinlyticCore.Models.Settings;
namespace FinlyticTrades.Util;
public static class SettingKeys
{
// --- Logging-Kanäle ---
public static readonly SettingKey<bool> TradesChannel = new("Logging.Channel.Trades", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
// --- Trade Management & Limits ---
public static readonly SettingKey<int> MaxActiveTradesCount = new("Trades.MaxActiveTradesCount", 20);
public static readonly SettingKey<int> AutoArchiveClosedTradesDays = new("Trades.AutoArchiveClosedTradesDays", 30);
public static readonly SettingKey<double> DefaultSlippageTolerancePercent = new("Trades.DefaultSlippageTolerancePercent", 0.5);
public static readonly SettingKey<int> ProposedTradeExpirationHours = new("Trades.ProposedTradeExpirationHours", 24);
// --- Parquet / Data Export ---
public static readonly SettingKey<bool> EnableParquetExport = new("Export.EnableParquetExport", true);
public static readonly SettingKey<int> ParquetExportIntervalHours = new("Export.ParquetExportIntervalHours", 6);
public static readonly SettingKey<string> ParquetExportDirectory = new("Export.ParquetExportDirectory", "data/exports/trades");
}
+105 -13
View File
@@ -6,9 +6,11 @@ using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticCore.Dtos; using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models; using FinlyticCore.Models;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using FinlyticCore.Util; using FinlyticCore.Util;
using FinlyticTrades.Entities; using FinlyticTrades.Entities;
using FinlyticTrades.Services; using FinlyticTrades.Services;
@@ -46,19 +48,19 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_trades")}_{Guid.NewGuid():N}" 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); await ConnectAsync(config);
} }
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
_logger.LogInformation("[{Channel}] Stopping Unified Trades MQTT Client.", "TradesChannel"); _logger.LogInformation("Stopping Unified Trades MQTT Client.");
await DisconnectAsync(); await DisconnectAsync();
} }
protected override async Task OnConnectedAsync() 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/proposed/#");
await SubscribeAsync("finlytic/trades/updates/#"); 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_Close/#");
await SubscribeAsync("services/request/trades_Reject/#"); await SubscribeAsync("services/request/trades_Reject/#");
await SubscribeAsync("services/request/trades_Accept/#"); 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/config/updated/#");
await SubscribeAsync("services/request/health_Ping/#"); await SubscribeAsync("services/request/health_Ping/#");
await SubscribeAsync("services/response/tr_GetLivePrice/#"); 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) 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}"; string respTopic = $"services/response/health_Ping/{correlationId}";
var healthResp = new ServiceHealthResponse("FinlyticTrades", "Online", DateTime.UtcNow, "Connected"); var healthResp = new ServiceHealthResponse("FinlyticTrades", "Online", DateTime.UtcNow, "Connected");
await PublishAsync(respTopic, healthResp); 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<IFinlyticLogger<TradesMqttClient>>();
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[TradesMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
} }
return; return;
} }
@@ -100,22 +114,35 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService
{ {
if (topic.EndsWith("FinlyticTrades", StringComparison.OrdinalIgnoreCase)) 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); var payload = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
if (payload?.Settings != null && payload.Settings.Count > 0) if (payload?.Settings != null && payload.Settings.Count > 0)
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>(); var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await settingsDb.UpdateSettingsFromDictionaryAsync(payload.Settings); var dict = payload.Settings.ToDictionary(k => k.Key, v => (object?)v.Value);
_logger.LogInformation("[{Channel}] [TradesMqttClient] Persisted {Count} updated settings to FinlyticTrades database.", "TradesChannel", payload.Settings.Count); await settings.UpdateSettingsAsync(dict);
} }
} }
return; 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(); using var msgScope = _scopeFactory.CreateScope();
var tradeLifecycleService = msgScope.ServiceProvider.GetRequiredService<ITradeLifecycleService>(); var tradeLifecycleService = msgScope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
var finlyticLoggerInstance = msgScope.ServiceProvider.GetRequiredService<IFinlyticLogger<TradesMqttClient>>();
if (topic.StartsWith("finlytic/trades/proposed/")) if (topic.StartsWith("finlytic/trades/proposed/"))
{ {
@@ -126,7 +153,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService
} }
else 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/")) else if (topic.StartsWith("finlytic/trades/accept/"))
@@ -199,7 +226,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService
} }
catch (Exception ex) 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) 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<IFinlyticLogger<TradesMqttClient>>();
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<IFinlyticLogger<TradesMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
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<IFinlyticLogger<TradesMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticTrades] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
try
{
Dictionary<string, object?>? updates = null;
try
{
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
}
catch
{
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
if (list != null)
{
updates = new Dictionary<string, object?>();
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.");
} }
} }