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 FinlyticTrades.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticTrades.Database;
public class TradesDbContext : DbContext
public class TradesDbContext : DbContext, ISettingsDbContext
{
public TradesDbContext(DbContextOptions<TradesDbContext> options) : base(options) { }
@@ -20,7 +25,7 @@ public class TradesDbContext : DbContext
modelBuilder.Entity<SettingEntity>(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<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.HasIndex("Key");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
+10 -9
View File
@@ -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<TradesDbContext>(options =>
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<ISettingsDbService, SettingsDbService>();
// 3. Hosted Services / Singletons
// 4. Hosted Services / Singletons
builder.Services.AddSingleton<TradesMqttClient>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TradesMqttClient>());
builder.Services.AddHostedService<FeedbackExporterEngine>();
@@ -34,14 +39,10 @@ using (var scope = host.Services.CreateScope())
var context = scope.ServiceProvider.GetRequiredService<TradesDbContext>();
await context.Database.MigrateAsync();
Console.WriteLine("Database migrations successfully executed for FinlyticTrades.");
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
await settingsService.GetSettingsAsync();
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred during database migration for FinlyticTrades on startup.");
Console.WriteLine($"Critical error during database migration for FinlyticTrades: {ex.Message}");
}
}
@@ -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<FeedbackExporterEngine> _logger;
private readonly IFinlyticLogger<FeedbackExporterEngine> _finlyticLogger;
private readonly string _feedbackDir;
public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, ILogger<FeedbackExporterEngine> logger)
public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, IFinlyticLogger<FeedbackExporterEngine> 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.");
}
/// <summary>
@@ -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)
@@ -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<TradeLifecycleService> _logger;
private readonly IFinlyticLogger<TradeLifecycleService> _finlyticLogger;
public TradeLifecycleService(TradesDbContext dbContext, ILogger<TradeLifecycleService> logger)
public TradeLifecycleService(TradesDbContext dbContext, IFinlyticLogger<TradeLifecycleService> finlyticLogger)
{
_dbContext = dbContext;
_logger = logger;
_finlyticLogger = finlyticLogger;
}
public async Task<bool> 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<List<TradeEntity>> 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;
}
+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.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<IFinlyticLogger<TradesMqttClient>>();
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<ISettingsDbService>();
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<ISettingsService>();
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<ITradeLifecycleService>();
var finlyticLoggerInstance = msgScope.ServiceProvider.GetRequiredService<IFinlyticLogger<TradesMqttClient>>();
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<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.");
}
}