diff --git a/FinlyticTrades/Database/TradesDbContext.cs b/FinlyticTrades/Database/TradesDbContext.cs index 5dd7717..e143fca 100644 --- a/FinlyticTrades/Database/TradesDbContext.cs +++ b/FinlyticTrades/Database/TradesDbContext.cs @@ -1,3 +1,4 @@ +using FinlyticCore.Entities.Settings; using FinlyticTrades.Entities; using Microsoft.EntityFrameworkCore; @@ -7,6 +8,7 @@ public class TradesDbContext : DbContext { public TradesDbContext(DbContextOptions options) : base(options) { } + public DbSet DynamicSettings => Set(); public DbSet Trades => Set(); public DbSet TradeHourlyUpdates => Set(); public DbSet Settings => Set(); @@ -15,6 +17,12 @@ public class TradesDbContext : DbContext { base.OnModelCreating(modelBuilder); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Key); + }); + modelBuilder.Entity(entity => { entity.HasIndex(e => e.TradeId).IsUnique(); diff --git a/FinlyticTrades/Entities/TradeEntity.cs b/FinlyticTrades/Entities/TradeEntity.cs index ebef3cc..63518b1 100644 --- a/FinlyticTrades/Entities/TradeEntity.cs +++ b/FinlyticTrades/Entities/TradeEntity.cs @@ -68,6 +68,9 @@ public class TradeEntity [MaxLength(30)] public string InstrumentType { get; set; } = "Stock"; + [MaxLength(20)] + public string? DerivativeIsin { get; set; } + public double WinRate { get; set; } public VixMarketRegime VixRegime { get; set; } diff --git a/FinlyticTrades/Migrations/20260812190325_DerivativeIsin.Designer.cs b/FinlyticTrades/Migrations/20260812190325_DerivativeIsin.Designer.cs new file mode 100644 index 0000000..1e4ea92 --- /dev/null +++ b/FinlyticTrades/Migrations/20260812190325_DerivativeIsin.Designer.cs @@ -0,0 +1,315 @@ +// +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("20260812190325_DerivativeIsin")] + partial class DerivativeIsin + { + /// + 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("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("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("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("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/20260812190325_DerivativeIsin.cs b/FinlyticTrades/Migrations/20260812190325_DerivativeIsin.cs new file mode 100644 index 0000000..15f6bfd --- /dev/null +++ b/FinlyticTrades/Migrations/20260812190325_DerivativeIsin.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class DerivativeIsin : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DerivativeIsin", + table: "trades", + type: "character varying(20)", + maxLength: 20, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DerivativeIsin", + table: "trades"); + } + } +} diff --git a/FinlyticTrades/Migrations/20260813202535_CheckPendingTrades.Designer.cs b/FinlyticTrades/Migrations/20260813202535_CheckPendingTrades.Designer.cs new file mode 100644 index 0000000..eefb2c6 --- /dev/null +++ b/FinlyticTrades/Migrations/20260813202535_CheckPendingTrades.Designer.cs @@ -0,0 +1,315 @@ +// +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("20260813202535_CheckPendingTrades")] + partial class CheckPendingTrades + { + /// + 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("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("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("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("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/20260813202535_CheckPendingTrades.cs b/FinlyticTrades/Migrations/20260813202535_CheckPendingTrades.cs new file mode 100644 index 0000000..647ff7b --- /dev/null +++ b/FinlyticTrades/Migrations/20260813202535_CheckPendingTrades.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class CheckPendingTrades : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs index d622b22..aee5726 100644 --- a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs +++ b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs @@ -51,6 +51,10 @@ namespace FinlyticTrades.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DerivativeIsin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + b.Property("EntryFee") .HasColumnType("decimal(18,4)"); diff --git a/FinlyticTrades/Program.cs b/FinlyticTrades/Program.cs index 59b903c..b44e3c0 100644 --- a/FinlyticTrades/Program.cs +++ b/FinlyticTrades/Program.cs @@ -11,22 +11,22 @@ using Microsoft.Extensions.Logging; var builder = Host.CreateApplicationBuilder(args); -// Register DB Context +// 1. Standard DbContext (Scoped) builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); -// Register Domain Services -builder.Services.AddSingleton(); +// 2. Domain Services (Scoped) +builder.Services.AddScoped(); builder.Services.AddScoped(); -// Register Hosted Services +// 3. Hosted Services / Singletons builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); var host = builder.Build(); -// Run DB Migrations +// DB Migrations ausführen using (var scope = host.Services.CreateScope()) { try @@ -45,4 +45,4 @@ using (var scope = host.Services.CreateScope()) } } -await host.RunAsync(); +await host.RunAsync(); \ No newline at end of file diff --git a/FinlyticTrades/Services/TradeLifecycleService.cs b/FinlyticTrades/Services/TradeLifecycleService.cs index 6b1c107..9ce2060 100644 --- a/FinlyticTrades/Services/TradeLifecycleService.cs +++ b/FinlyticTrades/Services/TradeLifecycleService.cs @@ -14,45 +14,15 @@ namespace FinlyticTrades.Services; public interface ITradeLifecycleService { - /// - /// Processes a proposed trade. - /// Task ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default); - - /// - /// Processes a manual analysis RPC response from FinlyticAnalyzer and ingests it if a trade was proposed. - /// Task ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default); - - /// - /// Accepts a trade proposal and maps execution parameters. - /// Task AcceptTradeAsync(TradeAcceptanceDto request, CancellationToken cancellationToken = default); - - /// - /// Adds an hourly update for a trade. - /// Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default); - - /// - /// Gets a list of active trades filtered by optional UserId. - /// Task> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default); - - /// - /// Gets a list of trades filtered by ISIN, status, and optional UserId. - /// Task> GetTradesAsync(string? isin, string? status, string? userId = null, CancellationToken cancellationToken = default); - - /// - /// Closes a trade manually. - /// Task CloseTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default); - - /// - /// Rejects a trade proposal. - /// Task RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default); + void CalculatePnL(TradeEntity trade, decimal? overridePrice = null); } public class TradeLifecycleService : ITradeLifecycleService @@ -66,9 +36,6 @@ public class TradeLifecycleService : ITradeLifecycleService _logger = logger; } - /// - /// Processes a manual analysis RPC response from FinlyticAnalyzer and ingests it if a trade was proposed. - /// public async Task ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default) { if (response == null || !response.IsTradeProposed) @@ -119,9 +86,6 @@ public class TradeLifecycleService : ITradeLifecycleService return false; } - /// - /// Processes a proposed trade. - /// public async Task ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(proposal.Symbol) && string.IsNullOrWhiteSpace(proposal.Isin)) @@ -177,9 +141,6 @@ public class TradeLifecycleService : ITradeLifecycleService return true; } - /// - /// Accepts a trade proposal and updates execution parameters. - /// public async Task AcceptTradeAsync(TradeAcceptanceDto request, CancellationToken cancellationToken = default) { string targetUserId = !string.IsNullOrWhiteSpace(request.UserId) ? request.UserId : "default_user"; @@ -212,6 +173,7 @@ public class TradeLifecycleService : ITradeLifecycleService if (request.TakeProfit > 0) existingTrade.TakeProfit = request.TakeProfit.Value; if (request.KnockoutThreshold > 0) existingTrade.KnockoutThreshold = request.KnockoutThreshold; if (!string.IsNullOrWhiteSpace(request.Timeframe)) existingTrade.Timeframe = request.Timeframe; + if (!string.IsNullOrWhiteSpace(request.DerivativeIsin)) existingTrade.DerivativeIsin = request.DerivativeIsin; if (!string.IsNullOrWhiteSpace(request.Reasoning)) existingTrade.Reasoning = request.Reasoning; existingTrade.ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; @@ -241,10 +203,10 @@ public class TradeLifecycleService : ITradeLifecycleService TradeId = targetTradeId, AnalysisId = proposal?.AnalysisId ?? (string.IsNullOrWhiteSpace(request.AnalysisId) ? Guid.NewGuid().ToString("N") : request.AnalysisId), EventId = proposal?.EventId ?? request.AnalysisId, - Sector = proposal?.Sector ?? "General", + Sector = proposal?.Sector ?? request.Sector ?? "General", Symbol = proposal?.Symbol ?? request.Symbol ?? request.Isin, Isin = proposal?.Isin ?? request.Isin, - CompanyName = proposal?.CompanyName ?? request.Symbol ?? request.Isin, + CompanyName = proposal?.CompanyName ?? request.CompanyName ?? request.Symbol ?? request.Isin, Status = TradeStatus.Active, IsGlobalProposal = false, UserId = targetUserId, @@ -256,6 +218,7 @@ public class TradeLifecycleService : ITradeLifecycleService RiskTolerance = proposal?.RiskTolerance ?? "Moderate", Timeframe = proposal?.Timeframe ?? request.Timeframe ?? "1D", InstrumentType = proposal?.InstrumentType ?? request.InstrumentType ?? "Stock", + DerivativeIsin = request.DerivativeIsin ?? proposal?.DerivativeIsin, WinRate = proposal?.WinRate ?? 50, VixRegime = proposal?.VixRegime ?? FinlyticCore.Models.Analyzer.VixMarketRegime.Normal, VixValue = proposal?.VixValue ?? 15, @@ -294,9 +257,6 @@ public class TradeLifecycleService : ITradeLifecycleService return newTrade; } - /// - /// Adds an hourly update for a trade. - /// public async Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default) { var trade = await _dbContext.Trades @@ -352,9 +312,6 @@ public class TradeLifecycleService : ITradeLifecycleService "TradesChannel", update.TradeId, update.Recommendation, update.CurrentPrice); } - /// - /// Gets a list of active trades filtered by optional UserId. - /// public async Task> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default) { var query = _dbContext.Trades.AsNoTracking().Include(t => t.HourlyUpdates).AsQueryable(); @@ -370,9 +327,6 @@ public class TradeLifecycleService : ITradeLifecycleService .ToListAsync(cancellationToken); } - /// - /// Gets a list of trades filtered by ISIN, status, and optional UserId. - /// public async Task> GetTradesAsync(string? isin, string? status, string? userId = null, CancellationToken cancellationToken = default) { var query = _dbContext.Trades.AsNoTracking().Include(t => t.HourlyUpdates).AsQueryable(); @@ -395,9 +349,6 @@ public class TradeLifecycleService : ITradeLifecycleService return await query.OrderByDescending(t => t.CreatedAt).ToListAsync(cancellationToken); } - /// - /// Closes a trade manually. - /// public async Task CloseTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default) { var trade = await _dbContext.Trades @@ -420,9 +371,6 @@ public class TradeLifecycleService : ITradeLifecycleService return trade; } - /// - /// Rejects a trade proposal. - /// public async Task RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default) { var trade = await _dbContext.Trades @@ -458,6 +406,7 @@ public class TradeLifecycleService : ITradeLifecycleService entity.RiskTolerance = dto.RiskTolerance; entity.Timeframe = dto.Timeframe; entity.InstrumentType = dto.InstrumentType; + if (!string.IsNullOrWhiteSpace(dto.DerivativeIsin)) entity.DerivativeIsin = dto.DerivativeIsin; entity.WinRate = dto.WinRate; entity.VixRegime = dto.VixRegime; entity.VixValue = dto.VixValue; @@ -484,11 +433,12 @@ public class TradeLifecycleService : ITradeLifecycleService entity.IsRecurring = dto.IsRecurring; } - private static void CalculatePnL(TradeEntity trade) + public void CalculatePnL(TradeEntity trade, decimal? overridePrice = null) { - if (!trade.UserExitPrice.HasValue) return; + decimal? evalPrice = overridePrice ?? trade.UserExitPrice ?? trade.HourlyUpdates?.LastOrDefault()?.CurrentPrice; + if (!evalPrice.HasValue || evalPrice.Value <= 0m) return; - decimal exitPrice = trade.UserExitPrice.Value; + decimal exitPrice = evalPrice.Value; decimal entryPrice = trade.ActualEntryPrice.HasValue && trade.ActualEntryPrice.Value > 0m ? trade.ActualEntryPrice.Value : trade.EntryPrice; diff --git a/FinlyticTrades/Util/TradesMqttClient.cs b/FinlyticTrades/Util/TradesMqttClient.cs index 6f4303d..0071773 100644 --- a/FinlyticTrades/Util/TradesMqttClient.cs +++ b/FinlyticTrades/Util/TradesMqttClient.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos; +using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models; using FinlyticCore.Models.Trades; using FinlyticCore.Util; @@ -21,18 +22,15 @@ namespace FinlyticTrades.Util; public class TradesMqttClient : ManagedMqttClient, IHostedService { private readonly IConfiguration _configuration; - private readonly ITradeLifecycleService _tradeLifecycleService; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public TradesMqttClient( IConfiguration configuration, - ITradeLifecycleService tradeLifecycleService, IServiceScopeFactory scopeFactory, ILogger logger) : base(logger) { _configuration = configuration; - _tradeLifecycleService = tradeLifecycleService; _scopeFactory = scopeFactory; _logger = logger; } @@ -61,7 +59,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService protected override async Task OnConnectedAsync() { _logger.LogInformation("[{Channel}] Trades MQTT Client connected. Subscribing to topics...", "TradesChannel"); - + await SubscribeAsync("finlytic/trades/proposed/#"); await SubscribeAsync("finlytic/trades/updates/#"); await SubscribeAsync("finlytic/trades/accept/#"); @@ -71,6 +69,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService await SubscribeAsync("services/request/trades_Accept/#"); 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"); } @@ -114,12 +113,16 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService return; } + // Für Scoped-Services erzeugen wir pro eingehender Nachricht einen eigenen Scope + using var msgScope = _scopeFactory.CreateScope(); + var tradeLifecycleService = msgScope.ServiceProvider.GetRequiredService(); + if (topic.StartsWith("finlytic/trades/proposed/")) { var proposal = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeProposalDto); if (proposal != null && (!string.IsNullOrWhiteSpace(proposal.Symbol) || !string.IsNullOrWhiteSpace(proposal.Isin))) { - await _tradeLifecycleService.ProcessProposedTradeAsync(proposal, CancellationToken.None); + await tradeLifecycleService.ProcessProposedTradeAsync(proposal, CancellationToken.None); } else { @@ -131,7 +134,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService var acceptDto = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeAcceptanceDto); if (acceptDto != null) { - var newTrade = await _tradeLifecycleService.AcceptTradeAsync(acceptDto, CancellationToken.None); + var newTrade = await tradeLifecycleService.AcceptTradeAsync(acceptDto, CancellationToken.None); if (newTrade != null) { var dto = MapToDto(newTrade); @@ -145,7 +148,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService var acceptDto = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeAcceptanceDto); if (acceptDto != null) { - var acceptedTrade = await _tradeLifecycleService.AcceptTradeAsync(acceptDto, CancellationToken.None); + var acceptedTrade = await tradeLifecycleService.AcceptTradeAsync(acceptDto, CancellationToken.None); if (acceptedTrade != null) { var acceptedDto = MapToDto(acceptedTrade); @@ -159,21 +162,48 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService var update = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeHourlyUpdateDto); if (update != null) { - await _tradeLifecycleService.AddHourlyUpdateAsync(update, CancellationToken.None); + await tradeLifecycleService.AddHourlyUpdateAsync(update, CancellationToken.None); } } else if (topic.StartsWith("services/request/trades_Get/")) { var correlationId = topic.Split('/').Last(); var request = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.GetTradesRequest); - + string? isin = request?.Isin; string? status = request?.Status; string? userId = request?.UserId; - var trades = await _tradeLifecycleService.GetTradesAsync(isin, status, userId); + var trades = await tradeLifecycleService.GetTradesAsync(isin, status, userId); + + var activeTrades = trades.Where(t => t.Status == TradeStatus.Active && !string.IsNullOrWhiteSpace(t.Isin)).ToList(); + if (activeTrades.Count > 0) + { + try + { + var priceTasks = activeTrades.Select(t => FetchLivePriceAsync(t.Isin)).ToList(); + var livePricesTask = Task.WhenAll(priceTasks); + if (await Task.WhenAny(livePricesTask, Task.Delay(1500)) == livePricesTask) + { + var livePrices = await livePricesTask; + for (int i = 0; i < activeTrades.Count; i++) + { + var lp = livePrices[i]; + if (lp != null && lp.CurrentPrice > 0m) + { + var trade = activeTrades[i]; + tradeLifecycleService.CalculatePnL(trade, lp.CurrentPrice); + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[{Channel}] Live price fetch skipped or timed out during trades_Get", "TradesChannel"); + } + } + var dtos = trades.Select(MapToDto).ToList(); - await PublishAsync($"services/response/trades_Get/{correlationId}", dtos); } else if (topic.StartsWith("services/request/trades_Close/")) @@ -181,18 +211,17 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService var parts = topic.Split('/'); var tradeId = parts.Length > 3 ? parts[3] : string.Empty; var correlationId = parts.Length > 4 ? parts[4] : string.Empty; - + var request = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.CloseTradeRequest); if (request != null && !string.IsNullOrEmpty(tradeId)) { - var closedTrade = await _tradeLifecycleService.CloseTradeAsync(tradeId, request); + var closedTrade = await tradeLifecycleService.CloseTradeAsync(tradeId, request); if (closedTrade != null) { var closedDto = MapToDto(closedTrade); await PublishAsync($"services/response/trades_Close/{correlationId}", closedDto); - - // Send event stream update specifically for closed trades (used by Feedback Engine & Analytics) + string sectorSafe = string.IsNullOrWhiteSpace(closedTrade.Sector) ? "general" : closedTrade.Sector.ToLowerInvariant(); await PublishAsync($"finlytic/trades/closed/{sectorSafe}/{closedTrade.Symbol.ToLowerInvariant()}", closedDto); await PublishTradeUpdateAsync(closedDto); @@ -204,12 +233,12 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService var parts = topic.Split('/'); var tradeId = parts.Length > 3 ? parts[3] : string.Empty; var correlationId = parts.Length > 4 ? parts[4] : string.Empty; - + var request = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.CloseTradeRequest); if (request != null && !string.IsNullOrEmpty(tradeId)) { - var rejectedTrade = await _tradeLifecycleService.RejectTradeAsync(tradeId, request); + var rejectedTrade = await tradeLifecycleService.RejectTradeAsync(tradeId, request); if (rejectedTrade != null) { var rejectedDto = MapToDto(rejectedTrade); @@ -227,9 +256,26 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService public async Task PublishTradeUpdateAsync(TradeProposalDto trade) { + await PublishAsync($"finlytic/trades/user/{trade.UserId ?? "all"}", trade); await PublishAsync("finlytic/trades/update", trade); } + private async Task FetchLivePriceAsync(string isin) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + try + { + return await SendRpcRequestAsync( + "tr_GetLivePrice", + new IsinRequest(isin), + TimeSpan.FromMilliseconds(1200)); + } + catch + { + return null; + } + } + private static TradeProposalDto MapToDto(TradeEntity t) { List? parseTakeProfitTargets() @@ -279,7 +325,7 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService FundamentalRationale = t.FundamentalRationale, RiskWarning = t.RiskWarning, CreatedAt = t.CreatedAt, - + UserId = t.UserId, IsGlobalProposal = t.IsGlobalProposal, ActualEntryPrice = t.ActualEntryPrice, @@ -290,7 +336,10 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService ExecutionTimestamp = t.ExecutionTimestamp, Quantity = t.Quantity, KnockoutThreshold = t.KnockoutThreshold, - IsRecurring = t.IsRecurring + IsRecurring = t.IsRecurring, + PnlAbsolute = t.PnlAbsolute, + PnlPercent = t.PnlPercent, + CurrentPrice = t.UserExitPrice ?? t.HourlyUpdates?.LastOrDefault()?.CurrentPrice }; } } \ No newline at end of file