From e7427b74645d24b9ac10c2dfd8b3b6027762736b Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sun, 9 Aug 2026 21:01:42 +0200 Subject: [PATCH] feat(Trades): refactor trades MQTT client and DTOs --- FinlyticTrades/Database/TradesDbContext.cs | 35 ++ FinlyticTrades/Dockerfile | 16 + FinlyticTrades/Entities/TradeEntity.cs | 146 +++++ .../Entities/TradeHourlyUpdateEntity.cs | 43 ++ .../Entities/TradesSettingsEntity.cs | 15 + FinlyticTrades/FinlyticTrades.csproj | 24 + .../20260801073417_Init.Designer.cs | 245 ++++++++ .../Migrations/20260801073417_Init.cs | 153 +++++ ...260802205654_ExpandTradeEntity.Designer.cs | 272 +++++++++ .../20260802205654_ExpandTradeEntity.cs | 101 ++++ ...643_AddMultiUserTradeExecution.Designer.cs | 306 ++++++++++ ...260803170643_AddMultiUserTradeExecution.cs | 132 +++++ ...05_AddIndexToTradeHourlyUpdate.Designer.cs | 311 ++++++++++ ...60807210605_AddIndexToTradeHourlyUpdate.cs | 37 ++ .../TradesDbContextModelSnapshot.cs | 308 ++++++++++ FinlyticTrades/Program.cs | 48 ++ FinlyticTrades/Project.md | 36 ++ .../Services/FeedbackExporterEngine.cs | 206 +++++++ FinlyticTrades/Services/SettingsDbService.cs | 94 +++ .../Services/TradeLifecycleService.cs | 539 ++++++++++++++++++ FinlyticTrades/Util/TradesMqttClient.cs | 296 ++++++++++ 21 files changed, 3363 insertions(+) create mode 100644 FinlyticTrades/Database/TradesDbContext.cs create mode 100644 FinlyticTrades/Dockerfile create mode 100644 FinlyticTrades/Entities/TradeEntity.cs create mode 100644 FinlyticTrades/Entities/TradeHourlyUpdateEntity.cs create mode 100644 FinlyticTrades/Entities/TradesSettingsEntity.cs create mode 100644 FinlyticTrades/FinlyticTrades.csproj create mode 100644 FinlyticTrades/Migrations/20260801073417_Init.Designer.cs create mode 100644 FinlyticTrades/Migrations/20260801073417_Init.cs create mode 100644 FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.Designer.cs create mode 100644 FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.cs create mode 100644 FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.Designer.cs create mode 100644 FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.cs create mode 100644 FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.Designer.cs create mode 100644 FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.cs create mode 100644 FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs create mode 100644 FinlyticTrades/Program.cs create mode 100644 FinlyticTrades/Project.md create mode 100644 FinlyticTrades/Services/FeedbackExporterEngine.cs create mode 100644 FinlyticTrades/Services/SettingsDbService.cs create mode 100644 FinlyticTrades/Services/TradeLifecycleService.cs create mode 100644 FinlyticTrades/Util/TradesMqttClient.cs diff --git a/FinlyticTrades/Database/TradesDbContext.cs b/FinlyticTrades/Database/TradesDbContext.cs new file mode 100644 index 0000000..5dd7717 --- /dev/null +++ b/FinlyticTrades/Database/TradesDbContext.cs @@ -0,0 +1,35 @@ +using FinlyticTrades.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticTrades.Database; + +public class TradesDbContext : DbContext +{ + public TradesDbContext(DbContextOptions options) : base(options) { } + + public DbSet Trades => Set(); + public DbSet TradeHourlyUpdates => Set(); + public DbSet Settings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.TradeId).IsUnique(); + entity.HasIndex(e => e.AnalysisId); + entity.HasIndex(e => e.EventId); + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.Sector); + entity.HasIndex(e => e.Isin); + entity.HasIndex(e => e.CreatedAt); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.TradeId); + entity.HasIndex(e => e.Timestamp); + }); + } +} diff --git a/FinlyticTrades/Dockerfile b/FinlyticTrades/Dockerfile new file mode 100644 index 0000000..7a74084 --- /dev/null +++ b/FinlyticTrades/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +COPY ["FinlyticTrades/FinlyticTrades.csproj", "FinlyticTrades/"] +RUN dotnet restore "FinlyticTrades/FinlyticTrades.csproj" +COPY . . +WORKDIR "/src/FinlyticTrades" +RUN dotnet build "FinlyticTrades.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "FinlyticTrades.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticTrades.dll"] diff --git a/FinlyticTrades/Entities/TradeEntity.cs b/FinlyticTrades/Entities/TradeEntity.cs new file mode 100644 index 0000000..ebef3cc --- /dev/null +++ b/FinlyticTrades/Entities/TradeEntity.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Trades; + +namespace FinlyticTrades.Entities; + +[Table("trades")] +public class TradeEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(100)] + public string TradeId { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string AnalysisId { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string EventId { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Sector { get; set; } = string.Empty; + + [Required] + [MaxLength(30)] + public string Symbol { get; set; } = string.Empty; + + [Required] + [MaxLength(30)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(150)] + public string CompanyName { get; set; } = string.Empty; + + public TradeStatus Status { get; set; } = TradeStatus.Proposed; + + [MaxLength(100)] + public string? UserId { get; set; } + + public bool IsGlobalProposal { get; set; } = true; + + [Column(TypeName = "decimal(18,4)")] + public decimal EntryPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal StopLoss { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal TakeProfit { get; set; } + + [MaxLength(10)] + public string SignalType { get; set; } = "BUY"; + + [MaxLength(30)] + public string RiskTolerance { get; set; } = "Moderate"; + + [MaxLength(20)] + public string Timeframe { get; set; } = "1D"; + + [MaxLength(30)] + public string InstrumentType { get; set; } = "Stock"; + + public double WinRate { get; set; } + public VixMarketRegime VixRegime { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal VixValue { get; set; } + + public int TtlMinutes { get; set; } = 60; + public string Reasoning { get; set; } = string.Empty; + + // --- New Fields for Detailed Execution & Rationale --- + [Column(TypeName = "decimal(18,4)")] + public decimal? EntryZoneMin { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? EntryZoneMax { get; set; } + + public string? TakeProfitTargets { get; set; } // Stored as comma separated values + + [Column(TypeName = "decimal(18,4)")] + public decimal? RiskRewardRatio { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? MaxLeverage { get; set; } + + public string TechnicalRationale { get; set; } = string.Empty; + public string FundamentalRationale { get; set; } = string.Empty; + public string RiskWarning { get; set; } = string.Empty; + + // --- User Exit Data --- + [Column(TypeName = "decimal(18,4)")] + public decimal? UserExitPrice { get; set; } + + public DateTime? UserExitTimestamp { get; set; } + + // --- Real Trade Execution Data --- + [Column(TypeName = "decimal(18,4)")] + public decimal? ActualEntryPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? PositionSize { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? LeverageUsed { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? EntryFee { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? ExitFee { get; set; } + + public DateTime? ExecutionTimestamp { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? Quantity { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? KnockoutThreshold { get; set; } + + public bool IsRecurring { get; set; } = false; + + [MaxLength(50)] + public string? CloseReason { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? PnlAbsolute { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? PnlPercent { get; set; } + + public bool? IsWin { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ClosedAt { get; set; } + + public List HourlyUpdates { get; set; } = new(); +} diff --git a/FinlyticTrades/Entities/TradeHourlyUpdateEntity.cs b/FinlyticTrades/Entities/TradeHourlyUpdateEntity.cs new file mode 100644 index 0000000..9615a60 --- /dev/null +++ b/FinlyticTrades/Entities/TradeHourlyUpdateEntity.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticTrades.Entities; + +[Table("trade_hourly_updates")] +[Index(nameof(TradeId), nameof(Timestamp))] +public class TradeHourlyUpdateEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public Guid TradeId { get; set; } + + [ForeignKey(nameof(TradeId))] + public TradeEntity? Trade { get; set; } + + [Required] + [MaxLength(30)] + public string Recommendation { get; set; } = "Hold"; // "Hold", "AdjustSL", "AdjustTP", "Close" + + [Column(TypeName = "decimal(18,4)")] + public decimal CurrentPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? SuggestedStopLoss { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? SuggestedTakeProfit { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal VixValue { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? FloatingPnlPercent { get; set; } + + public string Reasoning { get; set; } = string.Empty; + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/FinlyticTrades/Entities/TradesSettingsEntity.cs b/FinlyticTrades/Entities/TradesSettingsEntity.cs new file mode 100644 index 0000000..a18e9d6 --- /dev/null +++ b/FinlyticTrades/Entities/TradesSettingsEntity.cs @@ -0,0 +1,15 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace FinlyticTrades.Entities; + +public class TradesSettingsEntity +{ + [Key] + public Guid Id { get; set; } + + public double AtrStopLossMultiplier { get; set; } = 1.5; + public double RiskPerTradePercentage { get; set; } = 1.0; + public int MaxOpenPositions { get; set; } = 5; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticTrades/FinlyticTrades.csproj b/FinlyticTrades/FinlyticTrades.csproj new file mode 100644 index 0000000..416af55 --- /dev/null +++ b/FinlyticTrades/FinlyticTrades.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/FinlyticTrades/Migrations/20260801073417_Init.Designer.cs b/FinlyticTrades/Migrations/20260801073417_Init.Designer.cs new file mode 100644 index 0000000..7753c08 --- /dev/null +++ b/FinlyticTrades/Migrations/20260801073417_Init.Designer.cs @@ -0,0 +1,245 @@ +// +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("20260801073417_Init")] + partial class Init + { + /// + 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("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("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstrumentType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("IsWin") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PnlAbsolute") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlPercent") + .HasColumnType("decimal(18,4)"); + + b.Property("Reasoning") + .IsRequired() + .HasColumnType("text"); + + b.Property("RiskTolerance") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + 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("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("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("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.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/20260801073417_Init.cs b/FinlyticTrades/Migrations/20260801073417_Init.cs new file mode 100644 index 0000000..a18b7ea --- /dev/null +++ b/FinlyticTrades/Migrations/20260801073417_Init.cs @@ -0,0 +1,153 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AtrStopLossMultiplier = table.Column(type: "double precision", nullable: false), + RiskPerTradePercentage = table.Column(type: "double precision", nullable: false), + MaxOpenPositions = table.Column(type: "integer", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "trades", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TradeId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AnalysisId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EventId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Sector = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Isin = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + CompanyName = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + Status = table.Column(type: "integer", nullable: false), + EntryPrice = table.Column(type: "numeric(18,4)", nullable: false), + StopLoss = table.Column(type: "numeric(18,4)", nullable: false), + TakeProfit = table.Column(type: "numeric(18,4)", nullable: false), + SignalType = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + RiskTolerance = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Timeframe = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + InstrumentType = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + WinRate = table.Column(type: "double precision", nullable: false), + VixRegime = table.Column(type: "integer", nullable: false), + VixValue = table.Column(type: "numeric(18,4)", nullable: false), + TtlMinutes = table.Column(type: "integer", nullable: false), + Reasoning = table.Column(type: "text", nullable: false), + UserExitPrice = table.Column(type: "numeric(18,4)", nullable: true), + UserExitTimestamp = table.Column(type: "timestamp with time zone", nullable: true), + CloseReason = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + PnlAbsolute = table.Column(type: "numeric(18,4)", nullable: true), + PnlPercent = table.Column(type: "numeric(18,4)", nullable: true), + IsWin = table.Column(type: "boolean", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ClosedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_trades", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "trade_hourly_updates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TradeId = table.Column(type: "uuid", nullable: false), + Recommendation = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + CurrentPrice = table.Column(type: "numeric(18,4)", nullable: false), + SuggestedStopLoss = table.Column(type: "numeric(18,4)", nullable: true), + SuggestedTakeProfit = table.Column(type: "numeric(18,4)", nullable: true), + VixValue = table.Column(type: "numeric(18,4)", nullable: false), + Reasoning = table.Column(type: "text", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_trade_hourly_updates", x => x.Id); + table.ForeignKey( + name: "FK_trade_hourly_updates_trades_TradeId", + column: x => x.TradeId, + principalTable: "trades", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_trade_hourly_updates_Timestamp", + table: "trade_hourly_updates", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_trade_hourly_updates_TradeId", + table: "trade_hourly_updates", + column: "TradeId"); + + migrationBuilder.CreateIndex( + name: "IX_trades_AnalysisId", + table: "trades", + column: "AnalysisId"); + + migrationBuilder.CreateIndex( + name: "IX_trades_CreatedAt", + table: "trades", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_trades_EventId", + table: "trades", + column: "EventId"); + + migrationBuilder.CreateIndex( + name: "IX_trades_Isin", + table: "trades", + column: "Isin"); + + migrationBuilder.CreateIndex( + name: "IX_trades_Sector", + table: "trades", + column: "Sector"); + + migrationBuilder.CreateIndex( + name: "IX_trades_Status", + table: "trades", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_trades_TradeId", + table: "trades", + column: "TradeId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Settings"); + + migrationBuilder.DropTable( + name: "trade_hourly_updates"); + + migrationBuilder.DropTable( + name: "trades"); + } + } +} diff --git a/FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.Designer.cs b/FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.Designer.cs new file mode 100644 index 0000000..ec1f3d8 --- /dev/null +++ b/FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.Designer.cs @@ -0,0 +1,272 @@ +// +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("20260802205654_ExpandTradeEntity")] + partial class ExpandTradeEntity + { + /// + 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("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("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("FundamentalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstrumentType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("IsWin") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("MaxLeverage") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlAbsolute") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlPercent") + .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("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("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.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/20260802205654_ExpandTradeEntity.cs b/FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.cs new file mode 100644 index 0000000..8c1ff27 --- /dev/null +++ b/FinlyticTrades/Migrations/20260802205654_ExpandTradeEntity.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class ExpandTradeEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EntryZoneMax", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "EntryZoneMin", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "FundamentalRationale", + table: "trades", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "MaxLeverage", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RiskRewardRatio", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RiskWarning", + table: "trades", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TakeProfitTargets", + table: "trades", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "TechnicalRationale", + table: "trades", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EntryZoneMax", + table: "trades"); + + migrationBuilder.DropColumn( + name: "EntryZoneMin", + table: "trades"); + + migrationBuilder.DropColumn( + name: "FundamentalRationale", + table: "trades"); + + migrationBuilder.DropColumn( + name: "MaxLeverage", + table: "trades"); + + migrationBuilder.DropColumn( + name: "RiskRewardRatio", + table: "trades"); + + migrationBuilder.DropColumn( + name: "RiskWarning", + table: "trades"); + + migrationBuilder.DropColumn( + name: "TakeProfitTargets", + table: "trades"); + + migrationBuilder.DropColumn( + name: "TechnicalRationale", + table: "trades"); + } + } +} diff --git a/FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.Designer.cs b/FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.Designer.cs new file mode 100644 index 0000000..da7d558 --- /dev/null +++ b/FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.Designer.cs @@ -0,0 +1,306 @@ +// +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("20260803170643_AddMultiUserTradeExecution")] + partial class AddMultiUserTradeExecution + { + /// + 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("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("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.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/20260803170643_AddMultiUserTradeExecution.cs b/FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.cs new file mode 100644 index 0000000..3a5d7bc --- /dev/null +++ b/FinlyticTrades/Migrations/20260803170643_AddMultiUserTradeExecution.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class AddMultiUserTradeExecution : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActualEntryPrice", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "EntryFee", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ExecutionTimestamp", + table: "trades", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "ExitFee", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsGlobalProposal", + table: "trades", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsRecurring", + table: "trades", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "KnockoutThreshold", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "LeverageUsed", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "PositionSize", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Quantity", + table: "trades", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "trades", + type: "character varying(100)", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ActualEntryPrice", + table: "trades"); + + migrationBuilder.DropColumn( + name: "EntryFee", + table: "trades"); + + migrationBuilder.DropColumn( + name: "ExecutionTimestamp", + table: "trades"); + + migrationBuilder.DropColumn( + name: "ExitFee", + table: "trades"); + + migrationBuilder.DropColumn( + name: "IsGlobalProposal", + table: "trades"); + + migrationBuilder.DropColumn( + name: "IsRecurring", + table: "trades"); + + migrationBuilder.DropColumn( + name: "KnockoutThreshold", + table: "trades"); + + migrationBuilder.DropColumn( + name: "LeverageUsed", + table: "trades"); + + migrationBuilder.DropColumn( + name: "PositionSize", + table: "trades"); + + migrationBuilder.DropColumn( + name: "Quantity", + table: "trades"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "trades"); + } + } +} diff --git a/FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.Designer.cs b/FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.Designer.cs new file mode 100644 index 0000000..6aaf0ce --- /dev/null +++ b/FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.Designer.cs @@ -0,0 +1,311 @@ +// +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("20260807210605_AddIndexToTradeHourlyUpdate")] + partial class AddIndexToTradeHourlyUpdate + { + /// + 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("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/20260807210605_AddIndexToTradeHourlyUpdate.cs b/FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.cs new file mode 100644 index 0000000..23d9713 --- /dev/null +++ b/FinlyticTrades/Migrations/20260807210605_AddIndexToTradeHourlyUpdate.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class AddIndexToTradeHourlyUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FloatingPnlPercent", + table: "trade_hourly_updates", + type: "numeric(18,4)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_trade_hourly_updates_TradeId_Timestamp", + table: "trade_hourly_updates", + columns: new[] { "TradeId", "Timestamp" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_trade_hourly_updates_TradeId_Timestamp", + table: "trade_hourly_updates"); + + migrationBuilder.DropColumn( + name: "FloatingPnlPercent", + table: "trade_hourly_updates"); + } + } +} diff --git a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs new file mode 100644 index 0000000..d622b22 --- /dev/null +++ b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs @@ -0,0 +1,308 @@ +// +using System; +using FinlyticTrades.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + [DbContext(typeof(TradesDbContext))] + partial class TradesDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("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/Program.cs b/FinlyticTrades/Program.cs new file mode 100644 index 0000000..59b903c --- /dev/null +++ b/FinlyticTrades/Program.cs @@ -0,0 +1,48 @@ +using System; +using FinlyticCore.Models.Trades; +using FinlyticTrades.Database; +using FinlyticTrades.Services; +using FinlyticTrades.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Register DB Context +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Register Domain Services +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// Register Hosted Services +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Run DB Migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + Console.WriteLine("Database migrations successfully executed for FinlyticTrades."); + + var settingsService = scope.ServiceProvider.GetRequiredService(); + await settingsService.GetSettingsAsync(); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogError(ex, "An error occurred during database migration for FinlyticTrades on startup."); + } +} + +await host.RunAsync(); diff --git a/FinlyticTrades/Project.md b/FinlyticTrades/Project.md new file mode 100644 index 0000000..a422a15 --- /dev/null +++ b/FinlyticTrades/Project.md @@ -0,0 +1,36 @@ +# Finlytic Trades Service + +Finlytic Trades is a C# microservice managing the full lifecycle of automated trade signals and positions. It handles proposed trade validation, position tracking, TTL expiration, hourly performance updates, and trade closure. + +--- + +## Core Modules & Architecture + +1. **Trade Lifecycle Engine (`ITradeLifecycleService`)**: + - Ingests trade proposals (`TradeProposalDto`), validates parameters (Entry, Stop Loss, Take Profit, Win Rate, Risk Tolerance), and tracks positions through `Active`, `Closed`, `Expired`, or `Cancelled` states. + +2. **TTL Worker Service (`TtlWorkerService`)**: + - Periodically checks active trades against Time-To-Live (`TtlMinutes`) constraints and automatically expires stale trades. + +3. **Feedback Exporter Engine (`FeedbackExporterEngine`)**: + - Exports trade outcome data (`TradeFeedbackRecord`) for AI model retraining and win-rate calibration. + +4. **MQTT RPC & Event Communication**: + - Subscribes to `finlytic/trades/proposed/#` and `finlytic/trades/updates/#`. + - Handles RPC requests on `finlytic/trades/get_active/request` and `finlytic/trades/close/request/#`. + - Publishes position updates to `finlytic/trades/update` and `finlytic/trades/get_active/response`. + +--- + +## Feature Status + +### Implemented Features +- [x] Full Trade Lifecycle Management (`TradesDbContext` with PostgreSQL indexes). +- [x] Automated TTL Expiration Worker (`TtlWorkerService`). +- [x] AI Feedback Record Exporter (`FeedbackExporterEngine`). +- [x] Pure Worker Service Architecture (`Host.CreateApplicationBuilder`, Kestrel HTTP server removed). +- [x] Zero-Allocation MQTT RPC handlers for active trades & trade closure. + +### Planned Features +- [ ] Automated Trailing Stop Loss adjustment engine based on ATR (Average True Range). +- [ ] Direct Broker API Execution integration (Trade Republic / Interactive Brokers automated order placement). diff --git a/FinlyticTrades/Services/FeedbackExporterEngine.cs b/FinlyticTrades/Services/FeedbackExporterEngine.cs new file mode 100644 index 0000000..1417866 --- /dev/null +++ b/FinlyticTrades/Services/FeedbackExporterEngine.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models.Trades; +using FinlyticTrades.Database; +using FinlyticTrades.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Parquet.Serialization; + +namespace FinlyticTrades.Services; + +public interface IFeedbackExporterEngine +{ + /// + /// Exports feedback data for closed trades. + /// + Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default); +} + + +public class FeedbackExporterEngine : BackgroundService, IFeedbackExporterEngine +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly string _feedbackDir; + + public FeedbackExporterEngine(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback"); + + if (!Directory.Exists(_feedbackDir)) + { + Directory.CreateDirectory(_feedbackDir); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("[{Channel}] Feedback Exporter Engine background service started.", "TradesChannel"); + + try + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ExportFeedbackDataAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error executing feedback exporter job.", "TradesChannel"); + } + + try + { + await Task.Delay(TimeSpan.FromHours(6), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + _logger.LogInformation("[{Channel}] Feedback Exporter Engine background service stopped.", "TradesChannel"); + } + + /// + /// Exports feedback data for closed trades into sector-based JSON and Parquet formats. + /// Uses atomic file-writes to avoid thread-lock conflicts with reader processes. + /// + public async Task ExportFeedbackDataAsync(CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var closedTrades = await dbContext.Trades + .AsNoTracking() + .Where(t => t.Status == TradeStatus.Closed && t.UserExitPrice.HasValue) + .ToListAsync(cancellationToken); + + if (closedTrades.Count == 0) + { + _logger.LogInformation("[{Channel}] No closed trades available for export.", "TradesChannel"); + return; + } + + var groups = closedTrades.GroupBy(t => SanitizeSectorName(t.Sector)); + + foreach (var group in groups) + { + if (cancellationToken.IsCancellationRequested) break; + + var sectorName = group.Key; + var sectorDir = Path.Combine(_feedbackDir, sectorName); + + if (!Directory.Exists(sectorDir)) + { + Directory.CreateDirectory(sectorDir); + } + + var feedbackRecords = new List(); + + foreach (var t in group) + { + var startTime = t.ExecutionTimestamp ?? t.CreatedAt; + var endTime = t.UserExitTimestamp ?? t.ClosedAt ?? DateTime.UtcNow; + double reactionDelay = Math.Max(0, (endTime - startTime).TotalMinutes); + + decimal exitPrice = t.UserExitPrice ?? t.EntryPrice; + + decimal entryPrice = t.ActualEntryPrice.HasValue && t.ActualEntryPrice.Value > 0 + ? t.ActualEntryPrice.Value + : t.EntryPrice; + + decimal slippagePct = t.EntryPrice > 0 + ? Math.Abs((entryPrice - t.EntryPrice) / t.EntryPrice) * 100.0m + : 0m; + + var rec = new TradeFeedbackRecord + { + TradeId = t.TradeId, + AnalysisId = t.AnalysisId, + Sector = t.Sector, + Symbol = t.Symbol, + Isin = t.Isin, + EntryPrice = entryPrice, + StopLoss = t.StopLoss, + TakeProfit = t.TakeProfit, + UserExitPrice = exitPrice, + PnlAbsolute = t.PnlAbsolute ?? 0m, + PnlPercent = t.PnlPercent ?? 0m, + IsWin = t.IsWin ?? false, + CloseReason = t.CloseReason ?? "Unknown", + VixRegime = t.VixRegime, + VixValue = t.VixValue, + ReactionDelayMinutes = Math.Round(reactionDelay, 2), + SlippagePercent = Math.Round(slippagePct, 2), + CreatedAt = t.CreatedAt, + ClosedAt = endTime + }; + + feedbackRecords.Add(rec); + } + + // 1. Atomic JSON Export (.tmp -> move) + string jsonPath = Path.Combine(sectorDir, $"{sectorName}_feedback.json"); + string jsonTmpPath = Path.Combine(sectorDir, $"{sectorName}_feedback.json.tmp"); + string jsonContent = JsonSerializer.Serialize(feedbackRecords, new JsonSerializerOptions { WriteIndented = true }); + + await File.WriteAllTextAsync(jsonTmpPath, jsonContent, cancellationToken); + File.Move(jsonTmpPath, jsonPath, overwrite: true); + + // 2. Atomic Parquet Export (.tmp -> move) + try + { + string parquetPath = Path.Combine(sectorDir, $"{sectorName}_feedback.parquet"); + string parquetTmpPath = Path.Combine(sectorDir, $"{sectorName}_feedback.parquet.tmp"); + + await using (var fileStream = new FileStream(parquetTmpPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true)) + { + await ParquetSerializer.SerializeAsync(feedbackRecords, fileStream, cancellationToken: cancellationToken); + } + + File.Move(parquetTmpPath, parquetPath, overwrite: true); + + _logger.LogInformation("[{Channel}] Exported Parquet feedback file for sector '{Sector}' to {ParquetPath}", "TradesChannel", sectorName, parquetPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Failed to write Parquet file for sector '{Sector}'. JSON file was written successfully.", "TradesChannel", sectorName); + } + } + + _logger.LogInformation("[{Channel}] Successfully exported feedback data for {Count} closed trades across {Sectors} sectors.", + "TradesChannel", closedTrades.Count, groups.Count()); + } + + private static string SanitizeSectorName(string? sector) + { + if (string.IsNullOrWhiteSpace(sector)) return "general"; + + var clean = Regex.Replace(sector.Trim().ToLowerInvariant(), @"[^a-z0-9_\-]", "_"); + return string.IsNullOrWhiteSpace(clean) ? "general" : clean; + } +} \ No newline at end of file diff --git a/FinlyticTrades/Services/SettingsDbService.cs b/FinlyticTrades/Services/SettingsDbService.cs new file mode 100644 index 0000000..6293dba --- /dev/null +++ b/FinlyticTrades/Services/SettingsDbService.cs @@ -0,0 +1,94 @@ +using FinlyticTrades.Database; +using FinlyticTrades.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticTrades.Services; + +public interface ISettingsDbService +{ + /// + /// Gets the current settings. + /// + Task GetSettingsAsync(); + /// + /// Saves the provided settings. + /// + Task SaveSettingsAsync(TradesSettingsEntity settings); + /// + /// Updates settings from a dictionary of key-value pairs. + /// + Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary); +} + +public class SettingsDbService : ISettingsDbService +{ + private readonly TradesDbContext _context; + + /// + /// Initializes a new instance of the SettingsDbService class. + /// + public SettingsDbService(TradesDbContext context) + { + _context = context; + } + + /// + /// Gets the current settings. + /// + public async Task GetSettingsAsync() + { + var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync(); + if (settings == null) + { + settings = new TradesSettingsEntity { Id = Guid.NewGuid() }; + _context.Settings.Add(settings); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + } + return settings; + } + + /// + /// Saves the provided settings. + /// + public async Task SaveSettingsAsync(TradesSettingsEntity settings) + { + var existing = await _context.Settings.FirstOrDefaultAsync(); + if (existing == null) + { + if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid(); + _context.Settings.Add(settings); + } + else + { + existing.AtrStopLossMultiplier = settings.AtrStopLossMultiplier; + existing.RiskPerTradePercentage = settings.RiskPerTradePercentage; + existing.MaxOpenPositions = settings.MaxOpenPositions; + existing.UpdatedAt = settings.UpdatedAt; + _context.Settings.Update(existing); + } + await _context.SaveChangesAsync(); + return settings; + } + + /// + /// Updates settings from a dictionary of key-value pairs. + /// + public async Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary) + { + var settings = await GetSettingsAsync(); + + foreach (var (key, value) in dictionary) + { + if (string.Equals(key, "AtrStopLossMultiplier", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var atr)) + settings.AtrStopLossMultiplier = atr; + else if (string.Equals(key, "RiskPerTradePercentage", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var risk)) + settings.RiskPerTradePercentage = risk; + else if (string.Equals(key, "MaxOpenPositions", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var maxPos)) + settings.MaxOpenPositions = maxPos; + } + + settings.UpdatedAt = DateTime.UtcNow; + await SaveSettingsAsync(settings); + } +} diff --git a/FinlyticTrades/Services/TradeLifecycleService.cs b/FinlyticTrades/Services/TradeLifecycleService.cs new file mode 100644 index 0000000..6b1c107 --- /dev/null +++ b/FinlyticTrades/Services/TradeLifecycleService.cs @@ -0,0 +1,539 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Trades; +using FinlyticTrades.Database; +using FinlyticTrades.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +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); +} + +public class TradeLifecycleService : ITradeLifecycleService +{ + private readonly TradesDbContext _dbContext; + private readonly ILogger _logger; + + public TradeLifecycleService(TradesDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _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) + { + _logger.LogInformation("[{Channel}] Manual analysis response indicated NO trade proposed (AnalysisId: {AnalysisId}). Skipping.", "TradesChannel", response?.AnalysisId); + return false; + } + + if (response.Proposal != null) + { + response.Proposal.UserId = userId; + return await ProcessProposedTradeAsync(response.Proposal, cancellationToken); + } + + if (response.N8nResponse != null) + { + var n8n = response.N8nResponse; + var exec = n8n.ExecutionPlan; + + var generatedProposal = new TradeProposalDto + { + TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(), + AnalysisId = response.AnalysisId, + EventId = response.AnalysisId, + UserId = userId, + IsGlobalProposal = false, + Status = "Proposed", + SignalType = string.Equals(n8n.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", + RiskTolerance = n8n.SuggestedRisk, + Timeframe = n8n.SuggestedTimeframe, + Reasoning = n8n.AiReasoning, + StopLoss = exec?.StopLoss ?? 0m, + TakeProfit = exec?.TakeProfitTargets?.FirstOrDefault() ?? 0m, + EntryZoneMin = exec?.EntryZone?.Min, + EntryZoneMax = exec?.EntryZone?.Max, + TakeProfitTargets = exec?.TakeProfitTargets, + RiskRewardRatio = exec?.RiskRewardRatio, + MaxLeverage = exec?.MaxLeverage, + TechnicalRationale = n8n.DetailedAnalysis?.TechnicalRationale ?? string.Empty, + FundamentalRationale = n8n.DetailedAnalysis?.FundamentalRationale ?? string.Empty, + RiskWarning = n8n.DetailedAnalysis?.RiskWarning ?? string.Empty, + CreatedAt = DateTime.UtcNow + }; + + return await ProcessProposedTradeAsync(generatedProposal, cancellationToken); + } + + return false; + } + + /// + /// Processes a proposed trade. + /// + public async Task ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(proposal.Symbol) && string.IsNullOrWhiteSpace(proposal.Isin)) + { + _logger.LogWarning("[{Channel}] ProcessProposedTradeAsync: Received proposal with missing Symbol and ISIN. Skipping.", "TradesChannel"); + return false; + } + + var targetStatus = string.Equals(proposal.Status, "Rejected", StringComparison.OrdinalIgnoreCase) + ? TradeStatus.Rejected + : TradeStatus.Proposed; + + var existingTrade = await _dbContext.Trades + .FirstOrDefaultAsync(t => + (!string.IsNullOrWhiteSpace(proposal.TradeId) && t.TradeId == proposal.TradeId) || + (!string.IsNullOrWhiteSpace(proposal.AnalysisId) && t.AnalysisId == proposal.AnalysisId), + cancellationToken); + + if (existingTrade != null) + { + if (existingTrade.Status != TradeStatus.Active && existingTrade.Status != TradeStatus.Closed) + { + existingTrade.Status = targetStatus; + } + + MapProposalToEntity(proposal, existingTrade); + _dbContext.Trades.Update(existingTrade); + await _dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("[{Channel}] Successfully UPDATED trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", + "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status); + + return true; + } + + string tradeId = !string.IsNullOrWhiteSpace(proposal.TradeId) ? proposal.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()); + + var tradeEntity = new TradeEntity + { + TradeId = tradeId, + CreatedAt = DateTime.UtcNow + }; + + MapProposalToEntity(proposal, tradeEntity); + tradeEntity.Status = targetStatus; + + _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); + + 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"; + + var existingTrade = await _dbContext.Trades + .FirstOrDefaultAsync(t => + (!string.IsNullOrEmpty(request.TradeId) && t.TradeId == request.TradeId) || + (!string.IsNullOrEmpty(request.AnalysisId) && t.AnalysisId == request.AnalysisId), cancellationToken); + + if (existingTrade != null) + { + if (existingTrade.Status == TradeStatus.Closed) + { + _logger.LogWarning("[{Channel}] Refused to accept trade {TradeId} because its status is CLOSED", "TradesChannel", existingTrade.TradeId); + return null; + } + + existingTrade.Status = TradeStatus.Active; + existingTrade.IsGlobalProposal = false; + existingTrade.UserId = targetUserId; + + if (request.ActualEntryPrice > 0) existingTrade.ActualEntryPrice = request.ActualEntryPrice; + if (request.EntryPrice > 0) existingTrade.EntryPrice = request.EntryPrice.Value; + if (request.PositionSize > 0) existingTrade.PositionSize = request.PositionSize; + if (request.LeverageUsed > 0) existingTrade.LeverageUsed = request.LeverageUsed; + if (request.Quantity > 0) existingTrade.Quantity = request.Quantity; + if (request.EntryFee.HasValue) existingTrade.EntryFee = request.EntryFee; + if (request.ExitFee.HasValue) existingTrade.ExitFee = request.ExitFee; + if (request.StopLoss > 0) existingTrade.StopLoss = request.StopLoss.Value; + 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.Reasoning)) existingTrade.Reasoning = request.Reasoning; + + existingTrade.ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; + + existingTrade.PnlAbsolute = -(existingTrade.EntryFee ?? 0m) - (existingTrade.ExitFee ?? 0m); + if (existingTrade.PositionSize > 0) + { + existingTrade.PnlPercent = (existingTrade.PnlAbsolute / existingTrade.PositionSize) * 100m; + } + + _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); + return existingTrade; + } + + var proposal = await _dbContext.Trades + .FirstOrDefaultAsync(t => t.IsGlobalProposal && + (!string.IsNullOrEmpty(request.AnalysisId) ? t.AnalysisId == request.AnalysisId : t.Isin == request.Isin), + cancellationToken); + + var targetTradeId = !string.IsNullOrWhiteSpace(request.TradeId) ? request.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()); + + var newTrade = new TradeEntity + { + TradeId = targetTradeId, + AnalysisId = proposal?.AnalysisId ?? (string.IsNullOrWhiteSpace(request.AnalysisId) ? Guid.NewGuid().ToString("N") : request.AnalysisId), + EventId = proposal?.EventId ?? request.AnalysisId, + Sector = proposal?.Sector ?? "General", + Symbol = proposal?.Symbol ?? request.Symbol ?? request.Isin, + Isin = proposal?.Isin ?? request.Isin, + CompanyName = proposal?.CompanyName ?? request.Symbol ?? request.Isin, + Status = TradeStatus.Active, + IsGlobalProposal = false, + UserId = targetUserId, + + EntryPrice = proposal?.EntryPrice ?? request.EntryPrice ?? request.ActualEntryPrice ?? 0m, + StopLoss = request.StopLoss > 0 ? request.StopLoss.Value : (proposal?.StopLoss ?? 0m), + TakeProfit = request.TakeProfit > 0 ? request.TakeProfit.Value : (proposal?.TakeProfit ?? 0m), + SignalType = proposal?.SignalType ?? request.SignalType ?? "BUY", + RiskTolerance = proposal?.RiskTolerance ?? "Moderate", + Timeframe = proposal?.Timeframe ?? request.Timeframe ?? "1D", + InstrumentType = proposal?.InstrumentType ?? request.InstrumentType ?? "Stock", + WinRate = proposal?.WinRate ?? 50, + VixRegime = proposal?.VixRegime ?? FinlyticCore.Models.Analyzer.VixMarketRegime.Normal, + VixValue = proposal?.VixValue ?? 15, + Reasoning = proposal?.Reasoning ?? request.Reasoning ?? "User Accepted Trade", + EntryZoneMin = proposal?.EntryZoneMin, + EntryZoneMax = proposal?.EntryZoneMax, + TakeProfitTargets = proposal?.TakeProfitTargets, + RiskRewardRatio = proposal?.RiskRewardRatio, + MaxLeverage = proposal?.MaxLeverage, + TechnicalRationale = proposal?.TechnicalRationale ?? string.Empty, + FundamentalRationale = proposal?.FundamentalRationale ?? string.Empty, + RiskWarning = proposal?.RiskWarning ?? string.Empty, + CreatedAt = DateTime.UtcNow, + + ActualEntryPrice = request.ActualEntryPrice > 0 ? request.ActualEntryPrice : (proposal?.EntryPrice ?? request.EntryPrice ?? 0m), + PositionSize = request.PositionSize, + LeverageUsed = request.LeverageUsed > 0 ? request.LeverageUsed : 1m, + EntryFee = request.EntryFee, + ExitFee = request.ExitFee, + ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow, + Quantity = request.Quantity, + KnockoutThreshold = request.KnockoutThreshold, + IsRecurring = request.IsRecurring + }; + + newTrade.PnlAbsolute = -(newTrade.EntryFee ?? 0m) - (newTrade.ExitFee ?? 0m); + if (newTrade.PositionSize > 0) + { + newTrade.PnlPercent = (newTrade.PnlAbsolute / newTrade.PositionSize) * 100m; + } + + _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); + return newTrade; + } + + /// + /// Adds an hourly update for a trade. + /// + public async Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default) + { + var trade = await _dbContext.Trades + .FirstOrDefaultAsync(t => t.TradeId == update.TradeId || t.Id.ToString() == update.TradeId, cancellationToken); + + 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); + return; + } + + var updateEntity = new TradeHourlyUpdateEntity + { + TradeId = trade.Id, + Recommendation = update.Recommendation, + CurrentPrice = update.CurrentPrice, + SuggestedStopLoss = update.SuggestedStopLoss, + SuggestedTakeProfit = update.SuggestedTakeProfit, + VixValue = update.VixValue, + Reasoning = update.Reasoning, + Timestamp = update.Timestamp + }; + + _dbContext.TradeHourlyUpdates.Add(updateEntity); + + if (update.SuggestedStopLoss.HasValue && update.SuggestedStopLoss > 0) + trade.StopLoss = update.SuggestedStopLoss.Value; + if (update.SuggestedTakeProfit.HasValue && update.SuggestedTakeProfit > 0) + trade.TakeProfit = update.SuggestedTakeProfit.Value; + + if (string.Equals(update.Recommendation, "Close", StringComparison.OrdinalIgnoreCase)) + { + if (trade.IsGlobalProposal || trade.Status == TradeStatus.Proposed) + { + trade.Status = TradeStatus.Invalidated; + trade.CloseReason = "ProposalInvalidated"; + trade.ClosedAt = DateTime.UtcNow; + } + else + { + trade.Status = TradeStatus.Closed; + trade.UserExitPrice = update.CurrentPrice; + trade.UserExitTimestamp = DateTime.UtcNow; + trade.CloseReason = "AiRecommendationClose"; + trade.ClosedAt = DateTime.UtcNow; + + CalculatePnL(trade); + } + } + + await _dbContext.SaveChangesAsync(cancellationToken); + _logger.LogInformation("[{Channel}] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}", + "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(); + + if (!string.IsNullOrWhiteSpace(userId)) + { + query = query.Where(t => t.UserId == userId || t.IsGlobalProposal); + } + + return await query + .Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.Proposed) + .OrderByDescending(t => t.CreatedAt) + .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(); + + if (!string.IsNullOrWhiteSpace(userId)) + { + query = query.Where(t => t.UserId == userId || t.IsGlobalProposal); + } + + if (!string.IsNullOrWhiteSpace(isin)) + { + query = query.Where(t => t.Isin == isin); + } + + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var parsedStatus)) + { + query = query.Where(t => t.Status == parsedStatus); + } + + 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 + .FirstOrDefaultAsync(t => t.TradeId == tradeId || t.Id.ToString() == tradeId, cancellationToken); + + if (trade == null) return null; + + trade.Status = TradeStatus.Closed; + trade.UserExitPrice = request.UserExitPrice; + trade.UserExitTimestamp = request.UserExitTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; + trade.CloseReason = request.CloseReason; + trade.ClosedAt = DateTime.UtcNow; + + 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); + + return trade; + } + + /// + /// Rejects a trade proposal. + /// + public async Task RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default) + { + var trade = await _dbContext.Trades + .FirstOrDefaultAsync(t => t.TradeId == tradeId || t.Id.ToString() == tradeId, cancellationToken); + + if (trade == null) return null; + + trade.Status = TradeStatus.Rejected; + trade.CloseReason = request.CloseReason ?? "UserRejected"; + trade.ClosedAt = DateTime.UtcNow; + + await _dbContext.SaveChangesAsync(cancellationToken); + _logger.LogInformation("[{Channel}] Trade {TradeId} rejected by user.", "TradesChannel", trade.TradeId); + + return trade; + } + + private static void MapProposalToEntity(TradeProposalDto dto, TradeEntity entity) + { + entity.AnalysisId = dto.AnalysisId; + entity.EventId = dto.EventId; + entity.UserId = !string.IsNullOrWhiteSpace(dto.UserId) ? dto.UserId : (entity.UserId ?? "default_user"); + entity.IsGlobalProposal = dto.IsGlobalProposal; + entity.Sector = dto.Sector; + entity.Symbol = dto.Symbol; + entity.Isin = dto.Isin; + entity.CompanyName = dto.CompanyName; + + entity.EntryPrice = dto.EntryPrice; + entity.StopLoss = dto.StopLoss; + entity.TakeProfit = dto.TakeProfit; + entity.SignalType = dto.SignalType; + entity.RiskTolerance = dto.RiskTolerance; + entity.Timeframe = dto.Timeframe; + entity.InstrumentType = dto.InstrumentType; + entity.WinRate = dto.WinRate; + entity.VixRegime = dto.VixRegime; + entity.VixValue = dto.VixValue; + entity.TtlMinutes = dto.TtlMinutes; + entity.Reasoning = dto.Reasoning; + + entity.EntryZoneMin = dto.EntryZoneMin; + entity.EntryZoneMax = dto.EntryZoneMax; + entity.TakeProfitTargets = dto.TakeProfitTargets != null ? string.Join(",", dto.TakeProfitTargets) : entity.TakeProfitTargets; + entity.RiskRewardRatio = dto.RiskRewardRatio; + entity.MaxLeverage = dto.MaxLeverage; + entity.TechnicalRationale = dto.TechnicalRationale; + entity.FundamentalRationale = dto.FundamentalRationale; + entity.RiskWarning = dto.RiskWarning; + + if (dto.ActualEntryPrice.HasValue) entity.ActualEntryPrice = dto.ActualEntryPrice; + if (dto.PositionSize.HasValue) entity.PositionSize = dto.PositionSize; + if (dto.LeverageUsed.HasValue) entity.LeverageUsed = dto.LeverageUsed; + if (dto.EntryFee.HasValue) entity.EntryFee = dto.EntryFee; + if (dto.ExitFee.HasValue) entity.ExitFee = dto.ExitFee; + if (dto.ExecutionTimestamp.HasValue) entity.ExecutionTimestamp = dto.ExecutionTimestamp; + if (dto.Quantity.HasValue) entity.Quantity = dto.Quantity; + if (dto.KnockoutThreshold.HasValue) entity.KnockoutThreshold = dto.KnockoutThreshold; + entity.IsRecurring = dto.IsRecurring; + } + + private static void CalculatePnL(TradeEntity trade) + { + if (!trade.UserExitPrice.HasValue) return; + + decimal exitPrice = trade.UserExitPrice.Value; + decimal entryPrice = trade.ActualEntryPrice.HasValue && trade.ActualEntryPrice.Value > 0m + ? trade.ActualEntryPrice.Value + : trade.EntryPrice; + + if (entryPrice <= 0m) return; + + decimal positionSize = trade.PositionSize.HasValue && trade.PositionSize.Value > 0m + ? trade.PositionSize.Value + : ((trade.Quantity ?? 1m) * entryPrice); + + decimal entryFee = trade.EntryFee ?? 0m; + decimal exitFee = trade.ExitFee ?? 0m; + decimal totalFees = entryFee + exitFee; + + decimal rawMoveRatio; + bool isShort = string.Equals(trade.SignalType, "SELL", StringComparison.OrdinalIgnoreCase) || + string.Equals(trade.SignalType, "SHORT", StringComparison.OrdinalIgnoreCase); + + if (isShort) + { + rawMoveRatio = (entryPrice - exitPrice) / entryPrice; + } + else + { + rawMoveRatio = (exitPrice - entryPrice) / entryPrice; + } + + decimal pnlAbs; + if (string.Equals(trade.InstrumentType, "KnockOut", StringComparison.OrdinalIgnoreCase) || + string.Equals(trade.InstrumentType, "Certificate", StringComparison.OrdinalIgnoreCase) || + string.Equals(trade.InstrumentType, "Option", StringComparison.OrdinalIgnoreCase)) + { + pnlAbs = (rawMoveRatio * positionSize) - totalFees; + } + else + { + decimal leverage = trade.LeverageUsed > 0m ? trade.LeverageUsed.Value : 1m; + pnlAbs = (rawMoveRatio * positionSize * leverage) - totalFees; + } + + trade.PnlAbsolute = Math.Round(pnlAbs, 4); + trade.PnlPercent = positionSize > 0m + ? Math.Round((pnlAbs / positionSize) * 100.0m, 2) + : 0m; + + trade.IsWin = pnlAbs > 0m; + } +} \ No newline at end of file diff --git a/FinlyticTrades/Util/TradesMqttClient.cs b/FinlyticTrades/Util/TradesMqttClient.cs new file mode 100644 index 0000000..6f4303d --- /dev/null +++ b/FinlyticTrades/Util/TradesMqttClient.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Models; +using FinlyticCore.Models.Trades; +using FinlyticCore.Util; +using FinlyticTrades.Entities; +using FinlyticTrades.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +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; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = new MqttConfiguration + { + Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost", + Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"), + Username = _configuration["MQTT:Username"] ?? _configuration["MQTT__Username"], + Password = _configuration["MQTT:Password"] ?? _configuration["MQTT__Password"], + 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); + await ConnectAsync(config); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Stopping Unified Trades MQTT Client.", "TradesChannel"); + await DisconnectAsync(); + } + + 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/#"); + await SubscribeAsync("services/request/trades_Get/#"); + await SubscribeAsync("services/request/trades_Close/#"); + await SubscribeAsync("services/request/trades_Reject/#"); + await SubscribeAsync("services/request/trades_Accept/#"); + await SubscribeAsync("services/config/updated/#"); + await SubscribeAsync("services/request/health_Ping/#"); + + _logger.LogInformation("[{Channel}] Successfully subscribed to all event and RPC channels.", "TradesChannel"); + } + + protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) + { + try + { + if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + { + var segments = topic.Split('/'); + bool isForMe = segments.Length >= 5 + ? segments[3].Equals("FinlyticTrades", StringComparison.OrdinalIgnoreCase) + : topic.Contains("FinlyticTrades", StringComparison.OrdinalIgnoreCase); + + if (isForMe) + { + var correlationId = segments[^1]; + 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); + } + return; + } + + if (topic.StartsWith("services/config/updated", 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); + if (payload?.Settings != null && payload.Settings.Count > 0) + { + using var scope = _scopeFactory.CreateScope(); + var settingsDb = scope.ServiceProvider.GetRequiredService(); + await settingsDb.UpdateSettingsFromDictionaryAsync(payload.Settings); + _logger.LogInformation("[{Channel}] [TradesMqttClient] Persisted {Count} updated settings to FinlyticTrades database.", "TradesChannel", payload.Settings.Count); + } + } + return; + } + + 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); + } + else + { + _logger.LogWarning("[{Channel}] [TradesMqttClient] Received proposed trade payload but Symbol/ISIN is empty. Skipping ingestion.", "TradesChannel"); + } + } + else if (topic.StartsWith("finlytic/trades/accept/")) + { + var acceptDto = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeAcceptanceDto); + if (acceptDto != null) + { + var newTrade = await _tradeLifecycleService.AcceptTradeAsync(acceptDto, CancellationToken.None); + if (newTrade != null) + { + var dto = MapToDto(newTrade); + await PublishTradeUpdateAsync(dto); + } + } + } + else if (topic.StartsWith("services/request/trades_Accept/")) + { + var correlationId = topic.Split('/').Last(); + var acceptDto = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeAcceptanceDto); + if (acceptDto != null) + { + var acceptedTrade = await _tradeLifecycleService.AcceptTradeAsync(acceptDto, CancellationToken.None); + if (acceptedTrade != null) + { + var acceptedDto = MapToDto(acceptedTrade); + await PublishAsync($"services/response/trades_Accept/{correlationId}", acceptedDto); + await PublishTradeUpdateAsync(acceptedDto); + } + } + } + else if (topic.StartsWith("finlytic/trades/updates/")) + { + var update = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeHourlyUpdateDto); + if (update != null) + { + 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 dtos = trades.Select(MapToDto).ToList(); + + await PublishAsync($"services/response/trades_Get/{correlationId}", dtos); + } + else if (topic.StartsWith("services/request/trades_Close/")) + { + 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); + 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); + } + } + } + else if (topic.StartsWith("services/request/trades_Reject/")) + { + 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); + if (rejectedTrade != null) + { + var rejectedDto = MapToDto(rejectedTrade); + await PublishAsync($"services/response/trades_Reject/{correlationId}", rejectedDto); + await PublishTradeUpdateAsync(rejectedDto); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error processing incoming MQTT message on topic {Topic}", "TradesChannel", topic); + } + } + + public async Task PublishTradeUpdateAsync(TradeProposalDto trade) + { + await PublishAsync("finlytic/trades/update", trade); + } + + private static TradeProposalDto MapToDto(TradeEntity t) + { + List? parseTakeProfitTargets() + { + if (string.IsNullOrWhiteSpace(t.TakeProfitTargets)) return null; + + var list = new List(); + var parts = t.TakeProfitTargets.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var part in parts) + { + if (decimal.TryParse(part, NumberStyles.Number, CultureInfo.InvariantCulture, out var val)) + { + list.Add(val); + } + } + return list.Count > 0 ? list : null; + } + + return new TradeProposalDto + { + TradeId = t.TradeId, + Status = t.Status.ToString(), + AnalysisId = t.AnalysisId, + EventId = t.EventId, + Sector = t.Sector, + Symbol = t.Symbol, + Isin = t.Isin, + CompanyName = t.CompanyName, + EntryPrice = t.EntryPrice, + StopLoss = t.StopLoss, + TakeProfit = t.TakeProfit, + SignalType = t.SignalType, + RiskTolerance = t.RiskTolerance, + Timeframe = t.Timeframe, + InstrumentType = t.InstrumentType, + WinRate = t.WinRate, + VixRegime = t.VixRegime, + VixValue = t.VixValue, + TtlMinutes = t.TtlMinutes, + Reasoning = t.Reasoning, + EntryZoneMin = t.EntryZoneMin, + EntryZoneMax = t.EntryZoneMax, + TakeProfitTargets = parseTakeProfitTargets(), + RiskRewardRatio = t.RiskRewardRatio, + MaxLeverage = t.MaxLeverage, + TechnicalRationale = t.TechnicalRationale, + FundamentalRationale = t.FundamentalRationale, + RiskWarning = t.RiskWarning, + CreatedAt = t.CreatedAt, + + UserId = t.UserId, + IsGlobalProposal = t.IsGlobalProposal, + ActualEntryPrice = t.ActualEntryPrice, + PositionSize = t.PositionSize, + LeverageUsed = t.LeverageUsed, + EntryFee = t.EntryFee, + ExitFee = t.ExitFee, + ExecutionTimestamp = t.ExecutionTimestamp, + Quantity = t.Quantity, + KnockoutThreshold = t.KnockoutThreshold, + IsRecurring = t.IsRecurring + }; + } +} \ No newline at end of file