From 7060f0f7b113eb07b588d693d1a938233ff3368b Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Mon, 24 Aug 2026 21:37:10 +0200 Subject: [PATCH] refactor(bot): update paper trading models, broker integration, background services, and test project --- FinlyticBot.Tests/FinlyticBot.Tests.csproj | 27 ++ FinlyticBot/Database/BotDbContext.cs | 65 ++- .../Entities/BotPortfolioSnapshotEntity.cs | 33 ++ .../Database/Entities/BotPositionEntity.cs | 74 ++++ FinlyticBot/Dockerfile | 14 +- FinlyticBot/Entities/BotAuditLogEntity.cs | 37 -- .../Entities/ExecutedPaperTradeEntity.cs | 78 ---- FinlyticBot/FinlyticBot.csproj | 45 +- .../20260817142432_InitBotDatabase.cs | 153 ------- ...819191434_InitialBotMigration.Designer.cs} | 175 ++++---- .../20260819191434_InitialBotMigration.cs | 121 ++++++ .../Migrations/BotDbContextModelSnapshot.cs | 171 +++----- FinlyticBot/Program.cs | 39 +- .../Alpaca/AlpacaPaperTradingService.cs | 221 ++++++++++ .../Services/Alpaca/IAlpacaTradingService.cs | 57 +++ FinlyticBot/Services/AlpacaBrokerService.cs | 216 ---------- .../Services/AlpacaWebSocketMonitorWorker.cs | 208 --------- .../Services/BotOrderExecutionWorker.cs | 191 -------- FinlyticBot/Services/BotRiskSizingService.cs | 142 ------ ...EngineProposalConsumerBackgroundService.cs | 66 +++ .../Services/Execution/BotOrderExecutor.cs | 241 +++++++++++ .../Services/Execution/IBotOrderExecutor.cs | 15 + FinlyticBot/Services/IAlpacaBrokerService.cs | 26 -- FinlyticBot/Services/IBotRiskSizingService.cs | 25 -- .../Services/Ledger/ISyntheticPaperBroker.cs | 24 ++ .../Services/Ledger/SyntheticPaperBroker.cs | 167 +++++++ .../BotTradeLifecycleBackgroundService.cs | 252 +++++++++++ FinlyticBot/Services/Mqtt/IBotRpcClient.cs | 16 + FinlyticBot/Settings/BotSettingKeys.cs | 30 ++ FinlyticBot/Util/BotMqttClient.cs | 407 +++++++++++++----- FinlyticBot/Util/SettingKeys.cs | 36 -- FinlyticBot/appsettings.json | 9 +- 32 files changed, 1904 insertions(+), 1477 deletions(-) create mode 100644 FinlyticBot.Tests/FinlyticBot.Tests.csproj create mode 100644 FinlyticBot/Database/Entities/BotPortfolioSnapshotEntity.cs create mode 100644 FinlyticBot/Database/Entities/BotPositionEntity.cs delete mode 100644 FinlyticBot/Entities/BotAuditLogEntity.cs delete mode 100644 FinlyticBot/Entities/ExecutedPaperTradeEntity.cs delete mode 100644 FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs rename FinlyticBot/Migrations/{20260817142432_InitBotDatabase.Designer.cs => 20260819191434_InitialBotMigration.Designer.cs} (51%) create mode 100644 FinlyticBot/Migrations/20260819191434_InitialBotMigration.cs create mode 100644 FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs create mode 100644 FinlyticBot/Services/Alpaca/IAlpacaTradingService.cs delete mode 100644 FinlyticBot/Services/AlpacaBrokerService.cs delete mode 100644 FinlyticBot/Services/AlpacaWebSocketMonitorWorker.cs delete mode 100644 FinlyticBot/Services/BotOrderExecutionWorker.cs delete mode 100644 FinlyticBot/Services/BotRiskSizingService.cs create mode 100644 FinlyticBot/Services/Consumers/EngineProposalConsumerBackgroundService.cs create mode 100644 FinlyticBot/Services/Execution/BotOrderExecutor.cs create mode 100644 FinlyticBot/Services/Execution/IBotOrderExecutor.cs delete mode 100644 FinlyticBot/Services/IAlpacaBrokerService.cs delete mode 100644 FinlyticBot/Services/IBotRiskSizingService.cs create mode 100644 FinlyticBot/Services/Ledger/ISyntheticPaperBroker.cs create mode 100644 FinlyticBot/Services/Ledger/SyntheticPaperBroker.cs create mode 100644 FinlyticBot/Services/Monitoring/BotTradeLifecycleBackgroundService.cs create mode 100644 FinlyticBot/Services/Mqtt/IBotRpcClient.cs create mode 100644 FinlyticBot/Settings/BotSettingKeys.cs delete mode 100644 FinlyticBot/Util/SettingKeys.cs diff --git a/FinlyticBot.Tests/FinlyticBot.Tests.csproj b/FinlyticBot.Tests/FinlyticBot.Tests.csproj new file mode 100644 index 0000000..29ca0f4 --- /dev/null +++ b/FinlyticBot.Tests/FinlyticBot.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/FinlyticBot/Database/BotDbContext.cs b/FinlyticBot/Database/BotDbContext.cs index bb3356f..af48f3c 100644 --- a/FinlyticBot/Database/BotDbContext.cs +++ b/FinlyticBot/Database/BotDbContext.cs @@ -1,45 +1,78 @@ using System; -using FinlyticBot.Entities; +using System.Collections.Generic; +using System.Text.Json; using FinlyticCore.Database; +using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Entities.Settings; +using FinlyticBot.Database.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace FinlyticBot.Database; public class BotDbContext : DbContext, ISettingsDbContext { - public BotDbContext(DbContextOptions options) : base(options) { } + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public BotDbContext(DbContextOptions options) : base(options) + { + } public DbSet DynamicSettings => Set(); - public DbSet ExecutedPaperTrades => Set(); - public DbSet BotAuditLogs => Set(); + public DbSet Positions => Set(); + public DbSet PortfolioSnapshots => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + // 1. Settings Table modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => e.Key).IsUnique(); }); - modelBuilder.Entity(entity => + // 2. ExitPlan JSONB Converter + var exitPlanConverter = new ValueConverter( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List(), null, null, null, null) + ); + + // 3. Bot Positions Table + modelBuilder.Entity(entity => { - entity.HasIndex(e => e.TradeId).IsUnique(); - entity.HasIndex(e => e.Symbol); - entity.HasIndex(e => e.Isin); - entity.HasIndex(e => e.AlpacaOrderId); - entity.HasIndex(e => e.Status); - entity.HasIndex(e => e.PlacedAt); + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.Status, e.Venue }); + entity.HasIndex(e => e.OpenedAtUtc); + entity.HasIndex(e => e.ProposalId); + + entity.Property(e => e.ExitPlan) + .HasColumnType("jsonb") + .HasConversion(exitPlanConverter); }); - modelBuilder.Entity(entity => + // 4. Portfolio Snapshots Table + modelBuilder.Entity(entity => { - entity.HasIndex(e => e.TradeId); - entity.HasIndex(e => e.Symbol); - entity.HasIndex(e => e.Action); - entity.HasIndex(e => e.Timestamp); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.SnapshotDateUtc); }); } } + +public class BotDbContextFactory : IDesignTimeDbContextFactory +{ + public BotDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_bot;Username=postgres;Password=postgres"); + return new BotDbContext(optionsBuilder.Options); + } +} diff --git a/FinlyticBot/Database/Entities/BotPortfolioSnapshotEntity.cs b/FinlyticBot/Database/Entities/BotPortfolioSnapshotEntity.cs new file mode 100644 index 0000000..6358584 --- /dev/null +++ b/FinlyticBot/Database/Entities/BotPortfolioSnapshotEntity.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticBot.Database.Entities; + +[Table("bot_portfolio_snapshots")] +public class BotPortfolioSnapshotEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + public DateTime SnapshotDateUtc { get; set; } = DateTime.UtcNow; + + [Column(TypeName = "decimal(18,4)")] + public decimal TotalEquityEur { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal CashEur { get; set; } + + public int OpenPositionsCount { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal DailyRealizedPnlEur { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal TotalUnrealizedPnlEur { get; set; } + + [Column(TypeName = "decimal(6,2)")] + public decimal? WinRatePercent { get; set; } + + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticBot/Database/Entities/BotPositionEntity.cs b/FinlyticBot/Database/Entities/BotPositionEntity.cs new file mode 100644 index 0000000..9fb27db --- /dev/null +++ b/FinlyticBot/Database/Entities/BotPositionEntity.cs @@ -0,0 +1,74 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; + +namespace FinlyticBot.Database.Entities; + +[Table("bot_positions")] +public class BotPositionEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid ProposalId { get; set; } + + [Required] + [MaxLength(20)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(30)] + public string Symbol { get; set; } = string.Empty; + + public BotExecutionVenue Venue { get; set; } = BotExecutionVenue.SyntheticPaperBroker; + + [MaxLength(60)] + public string? AlpacaOrderId { get; set; } + + [MaxLength(60)] + public string? ClientOrderId { get; set; } + + public SignalDirection Direction { get; set; } = SignalDirection.Buy; + + [Column(TypeName = "decimal(18,4)")] + public decimal Quantity { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal EntryPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal AverageBuyIn { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal InitialStopLoss { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal CurrentStopLoss { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal CurrentPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal TakeProfit1 { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal TakeProfit2 { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal RealizedPnlEur { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal TotalFeesEur { get; set; } + + public BotPositionStatus Status { get; set; } = BotPositionStatus.Active; + + public ExitPlan ExitPlan { get; set; } = null!; + + public DateTime OpenedAtUtc { get; set; } = DateTime.UtcNow; + + public DateTime? ClosedAtUtc { get; set; } + + public DateTime LastSyncAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticBot/Dockerfile b/FinlyticBot/Dockerfile index 0638d23..25a232d 100644 --- a/FinlyticBot/Dockerfile +++ b/FinlyticBot/Dockerfile @@ -1,16 +1,22 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] COPY ["FinlyticBot/FinlyticBot.csproj", "FinlyticBot/"] +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] RUN dotnet restore "FinlyticBot/FinlyticBot.csproj" COPY . . WORKDIR "/src/FinlyticBot" -RUN dotnet build "FinlyticBot.csproj" -c Release -o /app/build +RUN dotnet build "FinlyticBot.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish -RUN dotnet publish "FinlyticBot.csproj" -c Release -o /app/publish /p:UseAppHost=false +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "FinlyticBot.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "FinlyticBot.dll"] diff --git a/FinlyticBot/Entities/BotAuditLogEntity.cs b/FinlyticBot/Entities/BotAuditLogEntity.cs deleted file mode 100644 index 9ae8463..0000000 --- a/FinlyticBot/Entities/BotAuditLogEntity.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace FinlyticBot.Entities; - -[Table("BotAuditLogs")] -public class BotAuditLogEntity -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - [MaxLength(100)] - public string TradeId { get; set; } = string.Empty; - - [Required] - [MaxLength(30)] - public string Symbol { get; set; } = string.Empty; - - [Required] - [MaxLength(50)] - public string Action { get; set; } = string.Empty; // "Evaluated", "Rejected", "OrderPlaced", "OrderFilled", "OrderClosed", "EmergencyStopped" - - public bool IsAccepted { get; set; } - - [MaxLength(500)] - public string? Reason { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? CalculatedSize { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? AccountEquity { get; set; } - - public DateTime Timestamp { get; set; } = DateTime.UtcNow; -} diff --git a/FinlyticBot/Entities/ExecutedPaperTradeEntity.cs b/FinlyticBot/Entities/ExecutedPaperTradeEntity.cs deleted file mode 100644 index 80e5cb2..0000000 --- a/FinlyticBot/Entities/ExecutedPaperTradeEntity.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace FinlyticBot.Entities; - -[Table("ExecutedPaperTrades")] -public class ExecutedPaperTradeEntity -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - [MaxLength(100)] - public string TradeId { get; set; } = string.Empty; - - [Required] - [MaxLength(30)] - public string Symbol { get; set; } = string.Empty; - - [MaxLength(30)] - public string Isin { get; set; } = string.Empty; - - [MaxLength(200)] - public string CompanyName { get; set; } = string.Empty; - - public Guid? AlpacaOrderId { get; set; } - - [MaxLength(20)] - public string Side { get; set; } = "BUY"; // "BUY", "SELL" - - [Column(TypeName = "decimal(18,4)")] - public decimal Quantity { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal SignalEntryPrice { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? ActualFillPrice { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal StopLossPrice { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal TakeProfitPrice1 { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? TakeProfitPrice2 { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal CalculatedCrv { get; set; } - - public double WinRate { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? SlippagePercent { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal RealizedPnl { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? RealizedPnlPercent { get; set; } - - [Required] - [MaxLength(30)] - public string Status { get; set; } = "Proposed"; // "Proposed", "Submitted", "Accepted", "PartiallyFilled", "Filled", "Closed", "Rejected", "Canceled" - - [MaxLength(1000)] - public string? RejectReason { get; set; } - - public DateTime PlacedAt { get; set; } = DateTime.UtcNow; - - public DateTime? FilledAt { get; set; } - - public DateTime? ClosedAt { get; set; } - - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; -} diff --git a/FinlyticBot/FinlyticBot.csproj b/FinlyticBot/FinlyticBot.csproj index bee023a..2d29c4b 100644 --- a/FinlyticBot/FinlyticBot.csproj +++ b/FinlyticBot/FinlyticBot.csproj @@ -1,24 +1,31 @@ - + - - net10.0 - enable - enable - + + net10.0 + enable + enable + Linux + - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + - - - + + + diff --git a/FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs b/FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs deleted file mode 100644 index a900a68..0000000 --- a/FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FinlyticBot.Migrations -{ - /// - public partial class InitBotDatabase : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "BotAuditLogs", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - TradeId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - Action = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - IsAccepted = table.Column(type: "boolean", nullable: false), - Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), - CalculatedSize = table.Column(type: "numeric(18,4)", nullable: true), - AccountEquity = table.Column(type: "numeric(18,4)", nullable: true), - Timestamp = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BotAuditLogs", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "DynamicSettings", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), - ValueJson = table.Column(type: "text", nullable: false), - ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DynamicSettings", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "ExecutedPaperTrades", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - TradeId = table.Column(type: "character varying(100)", maxLength: 100, 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(200)", maxLength: 200, nullable: false), - AlpacaOrderId = table.Column(type: "uuid", nullable: true), - Side = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Quantity = table.Column(type: "numeric(18,4)", nullable: false), - SignalEntryPrice = table.Column(type: "numeric(18,4)", nullable: false), - ActualFillPrice = table.Column(type: "numeric(18,4)", nullable: true), - StopLossPrice = table.Column(type: "numeric(18,4)", nullable: false), - TakeProfitPrice1 = table.Column(type: "numeric(18,4)", nullable: false), - TakeProfitPrice2 = table.Column(type: "numeric(18,4)", nullable: true), - CalculatedCrv = table.Column(type: "numeric(18,4)", nullable: false), - WinRate = table.Column(type: "double precision", nullable: false), - SlippagePercent = table.Column(type: "numeric(18,4)", nullable: true), - RealizedPnl = table.Column(type: "numeric(18,4)", nullable: false), - RealizedPnlPercent = table.Column(type: "numeric(18,4)", nullable: true), - Status = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - RejectReason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), - PlacedAt = table.Column(type: "timestamp with time zone", nullable: false), - FilledAt = table.Column(type: "timestamp with time zone", nullable: true), - ClosedAt = table.Column(type: "timestamp with time zone", nullable: true), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ExecutedPaperTrades", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_BotAuditLogs_Action", - table: "BotAuditLogs", - column: "Action"); - - migrationBuilder.CreateIndex( - name: "IX_BotAuditLogs_Symbol", - table: "BotAuditLogs", - column: "Symbol"); - - migrationBuilder.CreateIndex( - name: "IX_BotAuditLogs_Timestamp", - table: "BotAuditLogs", - column: "Timestamp"); - - migrationBuilder.CreateIndex( - name: "IX_BotAuditLogs_TradeId", - table: "BotAuditLogs", - column: "TradeId"); - - migrationBuilder.CreateIndex( - name: "IX_DynamicSettings_Key", - table: "DynamicSettings", - column: "Key", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_ExecutedPaperTrades_AlpacaOrderId", - table: "ExecutedPaperTrades", - column: "AlpacaOrderId"); - - migrationBuilder.CreateIndex( - name: "IX_ExecutedPaperTrades_Isin", - table: "ExecutedPaperTrades", - column: "Isin"); - - migrationBuilder.CreateIndex( - name: "IX_ExecutedPaperTrades_PlacedAt", - table: "ExecutedPaperTrades", - column: "PlacedAt"); - - migrationBuilder.CreateIndex( - name: "IX_ExecutedPaperTrades_Status", - table: "ExecutedPaperTrades", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_ExecutedPaperTrades_Symbol", - table: "ExecutedPaperTrades", - column: "Symbol"); - - migrationBuilder.CreateIndex( - name: "IX_ExecutedPaperTrades_TradeId", - table: "ExecutedPaperTrades", - column: "TradeId", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "BotAuditLogs"); - - migrationBuilder.DropTable( - name: "DynamicSettings"); - - migrationBuilder.DropTable( - name: "ExecutedPaperTrades"); - } - } -} diff --git a/FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs b/FinlyticBot/Migrations/20260819191434_InitialBotMigration.Designer.cs similarity index 51% rename from FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs rename to FinlyticBot/Migrations/20260819191434_InitialBotMigration.Designer.cs index 0913acd..742a452 100644 --- a/FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs +++ b/FinlyticBot/Migrations/20260819191434_InitialBotMigration.Designer.cs @@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace FinlyticBot.Migrations { [DbContext(typeof(BotDbContext))] - [Migration("20260817142432_InitBotDatabase")] - partial class InitBotDatabase + [Migration("20260819191434_InitialBotMigration")] + partial class InitialBotMigration { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -25,160 +25,131 @@ namespace FinlyticBot.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("FinlyticBot.Entities.BotAuditLogEntity", b => + modelBuilder.Entity("FinlyticBot.Database.Entities.BotPortfolioSnapshotEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("AccountEquity") + b.Property("CashEur") .HasColumnType("decimal(18,4)"); - b.Property("Action") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CalculatedSize") - .HasColumnType("decimal(18,4)"); - - b.Property("IsAccepted") - .HasColumnType("boolean"); - - b.Property("Reason") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Symbol") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("Timestamp") + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("TradeId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("DailyRealizedPnlEur") + .HasColumnType("decimal(18,4)"); + + b.Property("OpenPositionsCount") + .HasColumnType("integer"); + + b.Property("SnapshotDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalEquityEur") + .HasColumnType("decimal(18,4)"); + + b.Property("TotalUnrealizedPnlEur") + .HasColumnType("decimal(18,4)"); + + b.Property("WinRatePercent") + .HasColumnType("decimal(6,2)"); b.HasKey("Id"); - b.HasIndex("Action"); + b.HasIndex("SnapshotDateUtc"); - b.HasIndex("Symbol"); - - b.HasIndex("Timestamp"); - - b.HasIndex("TradeId"); - - b.ToTable("BotAuditLogs"); + b.ToTable("bot_portfolio_snapshots"); }); - modelBuilder.Entity("FinlyticBot.Entities.ExecutedPaperTradeEntity", b => + modelBuilder.Entity("FinlyticBot.Database.Entities.BotPositionEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("ActualFillPrice") + b.Property("AlpacaOrderId") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("AverageBuyIn") .HasColumnType("decimal(18,4)"); - b.Property("AlpacaOrderId") - .HasColumnType("uuid"); + b.Property("ClientOrderId") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); - b.Property("CalculatedCrv") - .HasColumnType("decimal(18,4)"); - - b.Property("ClosedAt") + b.Property("ClosedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("CompanyName") + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentStopLoss") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("ExitPlan") .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + .HasColumnType("jsonb"); - b.Property("FilledAt") - .HasColumnType("timestamp with time zone"); + b.Property("InitialStopLoss") + .HasColumnType("decimal(18,4)"); b.Property("Isin") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PlacedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("RealizedPnl") - .HasColumnType("decimal(18,4)"); - - b.Property("RealizedPnlPercent") - .HasColumnType("decimal(18,4)"); - - b.Property("RejectReason") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Side") .IsRequired() .HasMaxLength(20) .HasColumnType("character varying(20)"); - b.Property("SignalEntryPrice") + b.Property("LastSyncAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OpenedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") .HasColumnType("decimal(18,4)"); - b.Property("SlippagePercent") + b.Property("RealizedPnlEur") .HasColumnType("decimal(18,4)"); - b.Property("Status") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("StopLossPrice") - .HasColumnType("decimal(18,4)"); + b.Property("Status") + .HasColumnType("integer"); b.Property("Symbol") .IsRequired() .HasMaxLength(30) .HasColumnType("character varying(30)"); - b.Property("TakeProfitPrice1") + b.Property("TakeProfit1") .HasColumnType("decimal(18,4)"); - b.Property("TakeProfitPrice2") + b.Property("TakeProfit2") .HasColumnType("decimal(18,4)"); - b.Property("TradeId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("TotalFeesEur") + .HasColumnType("decimal(18,4)"); - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("WinRate") - .HasColumnType("double precision"); + b.Property("Venue") + .HasColumnType("integer"); b.HasKey("Id"); - b.HasIndex("AlpacaOrderId"); + b.HasIndex("OpenedAtUtc"); - b.HasIndex("Isin"); + b.HasIndex("ProposalId"); - b.HasIndex("PlacedAt"); + b.HasIndex("Status", "Venue"); - b.HasIndex("Status"); - - b.HasIndex("Symbol"); - - b.HasIndex("TradeId") - .IsUnique(); - - b.ToTable("ExecutedPaperTrades"); + b.ToTable("bot_positions"); }); modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => diff --git a/FinlyticBot/Migrations/20260819191434_InitialBotMigration.cs b/FinlyticBot/Migrations/20260819191434_InitialBotMigration.cs new file mode 100644 index 0000000..901ed9c --- /dev/null +++ b/FinlyticBot/Migrations/20260819191434_InitialBotMigration.cs @@ -0,0 +1,121 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticBot.Migrations +{ + /// + public partial class InitialBotMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "bot_portfolio_snapshots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SnapshotDateUtc = table.Column(type: "timestamp with time zone", nullable: false), + TotalEquityEur = table.Column(type: "numeric(18,4)", nullable: false), + CashEur = table.Column(type: "numeric(18,4)", nullable: false), + OpenPositionsCount = table.Column(type: "integer", nullable: false), + DailyRealizedPnlEur = table.Column(type: "numeric(18,4)", nullable: false), + TotalUnrealizedPnlEur = table.Column(type: "numeric(18,4)", nullable: false), + WinRatePercent = table.Column(type: "numeric(6,2)", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_bot_portfolio_snapshots", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "bot_positions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalId = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Venue = table.Column(type: "integer", nullable: false), + AlpacaOrderId = table.Column(type: "character varying(60)", maxLength: 60, nullable: true), + ClientOrderId = table.Column(type: "character varying(60)", maxLength: 60, nullable: true), + Direction = table.Column(type: "integer", nullable: false), + Quantity = table.Column(type: "numeric(18,4)", nullable: false), + EntryPrice = table.Column(type: "numeric(18,4)", nullable: false), + AverageBuyIn = table.Column(type: "numeric(18,4)", nullable: false), + InitialStopLoss = table.Column(type: "numeric(18,4)", nullable: false), + CurrentStopLoss = table.Column(type: "numeric(18,4)", nullable: false), + CurrentPrice = table.Column(type: "numeric(18,4)", nullable: false), + TakeProfit1 = table.Column(type: "numeric(18,4)", nullable: false), + TakeProfit2 = table.Column(type: "numeric(18,4)", nullable: false), + RealizedPnlEur = table.Column(type: "numeric(18,4)", nullable: false), + TotalFeesEur = table.Column(type: "numeric(18,4)", nullable: false), + Status = table.Column(type: "integer", nullable: false), + ExitPlan = table.Column(type: "jsonb", nullable: false), + OpenedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ClosedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastSyncAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_bot_positions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DynamicSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + ValueJson = table.Column(type: "text", nullable: false), + ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DynamicSettings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_bot_portfolio_snapshots_SnapshotDateUtc", + table: "bot_portfolio_snapshots", + column: "SnapshotDateUtc"); + + migrationBuilder.CreateIndex( + name: "IX_bot_positions_OpenedAtUtc", + table: "bot_positions", + column: "OpenedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_bot_positions_ProposalId", + table: "bot_positions", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_bot_positions_Status_Venue", + table: "bot_positions", + columns: new[] { "Status", "Venue" }); + + migrationBuilder.CreateIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "bot_portfolio_snapshots"); + + migrationBuilder.DropTable( + name: "bot_positions"); + + migrationBuilder.DropTable( + name: "DynamicSettings"); + } + } +} diff --git a/FinlyticBot/Migrations/BotDbContextModelSnapshot.cs b/FinlyticBot/Migrations/BotDbContextModelSnapshot.cs index 526a99e..f406aef 100644 --- a/FinlyticBot/Migrations/BotDbContextModelSnapshot.cs +++ b/FinlyticBot/Migrations/BotDbContextModelSnapshot.cs @@ -22,160 +22,131 @@ namespace FinlyticBot.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("FinlyticBot.Entities.BotAuditLogEntity", b => + modelBuilder.Entity("FinlyticBot.Database.Entities.BotPortfolioSnapshotEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("AccountEquity") + b.Property("CashEur") .HasColumnType("decimal(18,4)"); - b.Property("Action") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CalculatedSize") - .HasColumnType("decimal(18,4)"); - - b.Property("IsAccepted") - .HasColumnType("boolean"); - - b.Property("Reason") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Symbol") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("Timestamp") + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("TradeId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("DailyRealizedPnlEur") + .HasColumnType("decimal(18,4)"); + + b.Property("OpenPositionsCount") + .HasColumnType("integer"); + + b.Property("SnapshotDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalEquityEur") + .HasColumnType("decimal(18,4)"); + + b.Property("TotalUnrealizedPnlEur") + .HasColumnType("decimal(18,4)"); + + b.Property("WinRatePercent") + .HasColumnType("decimal(6,2)"); b.HasKey("Id"); - b.HasIndex("Action"); + b.HasIndex("SnapshotDateUtc"); - b.HasIndex("Symbol"); - - b.HasIndex("Timestamp"); - - b.HasIndex("TradeId"); - - b.ToTable("BotAuditLogs"); + b.ToTable("bot_portfolio_snapshots"); }); - modelBuilder.Entity("FinlyticBot.Entities.ExecutedPaperTradeEntity", b => + modelBuilder.Entity("FinlyticBot.Database.Entities.BotPositionEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("ActualFillPrice") + b.Property("AlpacaOrderId") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("AverageBuyIn") .HasColumnType("decimal(18,4)"); - b.Property("AlpacaOrderId") - .HasColumnType("uuid"); + b.Property("ClientOrderId") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); - b.Property("CalculatedCrv") - .HasColumnType("decimal(18,4)"); - - b.Property("ClosedAt") + b.Property("ClosedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("CompanyName") + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("CurrentStopLoss") + .HasColumnType("decimal(18,4)"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("ExitPlan") .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + .HasColumnType("jsonb"); - b.Property("FilledAt") - .HasColumnType("timestamp with time zone"); + b.Property("InitialStopLoss") + .HasColumnType("decimal(18,4)"); b.Property("Isin") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("PlacedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("RealizedPnl") - .HasColumnType("decimal(18,4)"); - - b.Property("RealizedPnlPercent") - .HasColumnType("decimal(18,4)"); - - b.Property("RejectReason") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Side") .IsRequired() .HasMaxLength(20) .HasColumnType("character varying(20)"); - b.Property("SignalEntryPrice") + b.Property("LastSyncAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OpenedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") .HasColumnType("decimal(18,4)"); - b.Property("SlippagePercent") + b.Property("RealizedPnlEur") .HasColumnType("decimal(18,4)"); - b.Property("Status") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("StopLossPrice") - .HasColumnType("decimal(18,4)"); + b.Property("Status") + .HasColumnType("integer"); b.Property("Symbol") .IsRequired() .HasMaxLength(30) .HasColumnType("character varying(30)"); - b.Property("TakeProfitPrice1") + b.Property("TakeProfit1") .HasColumnType("decimal(18,4)"); - b.Property("TakeProfitPrice2") + b.Property("TakeProfit2") .HasColumnType("decimal(18,4)"); - b.Property("TradeId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("TotalFeesEur") + .HasColumnType("decimal(18,4)"); - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("WinRate") - .HasColumnType("double precision"); + b.Property("Venue") + .HasColumnType("integer"); b.HasKey("Id"); - b.HasIndex("AlpacaOrderId"); + b.HasIndex("OpenedAtUtc"); - b.HasIndex("Isin"); + b.HasIndex("ProposalId"); - b.HasIndex("PlacedAt"); + b.HasIndex("Status", "Venue"); - b.HasIndex("Status"); - - b.HasIndex("Symbol"); - - b.HasIndex("TradeId") - .IsUnique(); - - b.ToTable("ExecutedPaperTrades"); + b.ToTable("bot_positions"); }); modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => diff --git a/FinlyticBot/Program.cs b/FinlyticBot/Program.cs index 81183fd..1b456fa 100644 --- a/FinlyticBot/Program.cs +++ b/FinlyticBot/Program.cs @@ -1,9 +1,14 @@ using System; -using FinlyticBot.Database; -using FinlyticBot.Services; -using FinlyticBot.Util; using FinlyticCore.Database; using FinlyticCore.Services; +using FinlyticBot.Database; +using FinlyticBot.Services.Alpaca; +using FinlyticBot.Services.Consumers; +using FinlyticBot.Services.Execution; +using FinlyticBot.Services.Ledger; +using FinlyticBot.Services.Monitoring; +using FinlyticBot.Services.Mqtt; +using FinlyticBot.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -11,39 +16,45 @@ using Microsoft.Extensions.Hosting; var builder = Host.CreateApplicationBuilder(args); -// 1. DbContext (Scoped) +// 1. Register DbContext & Settings builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddScoped(sp => sp.GetRequiredService()); -// 2. Core Services +// 2. Register Core Services & Logger builder.Services.AddSingleton(); builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); -// 3. Domain Services -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +// 3. Register Alpaca Trading Service & Synthetic Broker +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); -// 4. Hosted Services +// 4. Register MQTT Client & RPC Bridge builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -builder.Services.AddHostedService(); + +// 5. Register Background Services +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); var host = builder.Build(); -// Run DB Migrations +// Run startup database migrations using (var scope = host.Services.CreateScope()) { try { var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(); + var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; + await context.MigrateWithBootstrapAsync(connStr); Console.WriteLine("Database migrations successfully executed for FinlyticBot."); + } catch (Exception ex) { - Console.WriteLine($"Critical error during database migration for FinlyticBot: {ex.Message}"); + Console.WriteLine($"Migration notice on startup: {ex.Message}"); } } diff --git a/FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs b/FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs new file mode 100644 index 0000000..94893bd --- /dev/null +++ b/FinlyticBot/Services/Alpaca/AlpacaPaperTradingService.cs @@ -0,0 +1,221 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Alpaca.Markets; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; +using FinlyticBot.Settings; +using Microsoft.Extensions.Configuration; + +namespace FinlyticBot.Services.Alpaca; + +public class AlpacaPaperTradingService : IAlpacaTradingService +{ + private readonly ISettingsService _settingsService; + private readonly IConfiguration _configuration; + private readonly IFinlyticLogger _logger; + + private string _cachedKeyId = ""; + private string _cachedSecretKey = ""; + private bool _cachedIsPaper = true; + private IAlpacaTradingClient? _tradingClient; + private readonly SemaphoreSlim _clientLock = new(1, 1); + + public bool IsConfigured => _tradingClient != null || !string.IsNullOrWhiteSpace(_cachedKeyId); + + public AlpacaPaperTradingService( + ISettingsService settingsService, + IConfiguration configuration, + IFinlyticLogger logger) + { + _settingsService = settingsService; + _configuration = configuration; + _logger = logger; + } + + private async Task GetTradingClientAsync(CancellationToken cancellationToken = default) + { + var keyId = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaKeyId, cancellationToken); + if (string.IsNullOrWhiteSpace(keyId)) + { + keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? ""; + } + + var secretKey = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaSecretKey, cancellationToken); + if (string.IsNullOrWhiteSpace(secretKey)) + { + secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? ""; + } + + var isPaper = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaIsPaper, cancellationToken); + + keyId = keyId.Trim(); + secretKey = secretKey.Trim(); + + if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey) || keyId.Contains("PLACEHOLDER", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (_tradingClient != null && keyId == _cachedKeyId && secretKey == _cachedSecretKey && isPaper == _cachedIsPaper) + { + return _tradingClient; + } + + await _clientLock.WaitAsync(cancellationToken); + try + { + if (_tradingClient != null && keyId == _cachedKeyId && secretKey == _cachedSecretKey && isPaper == _cachedIsPaper) + { + return _tradingClient; + } + + var secretKeyObj = new SecretKey(keyId, secretKey); + var environment = isPaper ? global::Alpaca.Markets.Environments.Paper : global::Alpaca.Markets.Environments.Live; + _tradingClient = environment.GetAlpacaTradingClient(secretKeyObj); + _cachedKeyId = keyId; + _cachedSecretKey = secretKey; + _cachedIsPaper = isPaper; + + await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Initialized Alpaca Client (Paper: {IsPaper}) with Key {KeyIdPrefix}...", isPaper, keyId.Substring(0, Math.Min(4, keyId.Length))); + return _tradingClient; + } + catch (Exception ex) + { + await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex, "[AlpacaService] Failed to initialize Alpaca client."); + return null; + } + finally + { + _clientLock.Release(); + } + } + + public async Task PlaceBracketOrderAsync( + string symbol, + SignalDirection direction, + decimal quantity, + decimal entryPrice, + decimal stopLossPrice, + decimal takeProfitPrice, + CancellationToken cancellationToken = default) + { + var client = await GetTradingClientAsync(cancellationToken); + if (client == null) + { + throw new InvalidOperationException("Alpaca Paper Trading Client is not configured or offline."); + } + + var orderSide = direction == SignalDirection.Buy ? OrderSide.Buy : OrderSide.Sell; + var orderRequest = orderSide.Market(symbol, OrderQuantity.Fractional(quantity)) + .Bracket(takeProfitPrice, stopLossPrice); + + var order = await client.PostOrderAsync(orderRequest, cancellationToken); + + await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, + "[AlpacaService] Placed Alpaca Bracket Order {OrderId} for {Symbol} (Side: {Side}, Qty: {Qty}, SL: {SL:F2}, TP: {TP:F2})", + order.OrderId, symbol, orderSide, quantity, stopLossPrice, takeProfitPrice); + + return order.OrderId.ToString(); + } + + public async Task UpdateStopLossAsync( + string alpacaOrderId, + decimal newStopLossPrice, + CancellationToken cancellationToken = default) + { + var client = await GetTradingClientAsync(cancellationToken); + if (client == null) return; + + if (!Guid.TryParse(alpacaOrderId, out var orderGuid)) + { + await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, + "[AlpacaService] Invalid Alpaca Order ID format: {Id}", alpacaOrderId); + return; + } + + var replaceRequest = new ChangeOrderRequest(orderGuid) + { + StopPrice = newStopLossPrice + }; + + await client.PatchOrderAsync(replaceRequest, cancellationToken); + + await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, + "[AlpacaService] Updated Stop-Loss for Alpaca Order {OrderId} to {NewSL:F2}", orderGuid, newStopLossPrice); + } + + public async Task CancelOrderAsync(string alpacaOrderId, CancellationToken cancellationToken = default) + { + var client = await GetTradingClientAsync(cancellationToken); + if (client == null) return; + + if (Guid.TryParse(alpacaOrderId, out var orderGuid)) + { + await client.CancelOrderAsync(orderGuid, cancellationToken); + await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, + "[AlpacaService] Canceled Alpaca Order {OrderId}", orderGuid); + } + } + + /// + /// Liquidates the entire open position for at market price using Alpaca's + /// native DELETE /v2/positions/{symbol} endpoint (). + /// Unlike (which only ever submits an order and says nothing about + /// whether the position it opens is confirmed), a successful return from this method means Alpaca's REST + /// API has ACCEPTED the liquidation request for the position — this is the only signal in this service + /// that is safe to treat as authoritative proof of a close. Callers (see + /// FinlyticBot.Util.BotMqttClient's panic-close handler) must persist the position as closed only + /// after this call returns without throwing, never optimistically beforehand: a false "closed" marking on + /// a still-open real position is the single worst outcome a panic-close feature could produce. + /// + /// Alpaca is not configured/reachable — no liquidation was attempted. + public async Task ClosePositionAsync(string symbol, CancellationToken cancellationToken = default) + { + var client = await GetTradingClientAsync(cancellationToken); + if (client == null) + { + throw new InvalidOperationException( + $"Alpaca Paper Trading Client is not configured or offline. Cannot confirm liquidation of position '{symbol}'."); + } + + var order = await client.DeletePositionAsync(new DeletePositionRequest(symbol), cancellationToken); + + await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, + "[AlpacaService] Alpaca accepted market liquidation for position {Symbol}: Order {OrderId} (Status: {Status}, AvgFill: {AvgFill}).", + symbol, order.OrderId, order.OrderStatus, order.AverageFillPrice?.ToString("F2") ?? "n/a (not yet filled)"); + + return new AlpacaPositionCloseResult(order.OrderId.ToString(), order.OrderStatus.ToString(), order.AverageFillPrice); + } + + /// + /// Ruft die echte Alpaca-Kontoübersicht ab. Wirft eine , + /// wenn der Alpaca-Client nicht konfiguriert/initialisierbar ist - es werden bewusst KEINE + /// erfundenen Platzhalterzahlen zurückgegeben (Rules.md §4). Wer synthetisches Paper-Trading + /// betreiben möchte, muss explizit + /// als Venue wählen und den dedizierten Ledger (ISyntheticPaperBroker) verwenden. + /// + public async Task GetPortfolioSummaryAsync(CancellationToken cancellationToken = default) + { + var client = await GetTradingClientAsync(cancellationToken); + if (client == null) + { + await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, + "[AlpacaService] GetPortfolioSummaryAsync failed: Alpaca client is not configured or offline. Refusing to return synthetic placeholder data."); + throw new InvalidOperationException( + "Alpaca Paper Trading Client is not configured or offline. Keine echte Kontoübersicht verfügbar."); + } + + var account = await client.GetAccountAsync(cancellationToken); + + return new AccountSummaryDto( + Equity: account.Equity ?? 0m, + Cash: account.TradableCash, + BuyingPower: account.BuyingPower ?? 0m, + Currency: account.Currency ?? "USD", + Status: account.Status.ToString() + ); + } +} + diff --git a/FinlyticBot/Services/Alpaca/IAlpacaTradingService.cs b/FinlyticBot/Services/Alpaca/IAlpacaTradingService.cs new file mode 100644 index 0000000..2925db5 --- /dev/null +++ b/FinlyticBot/Services/Alpaca/IAlpacaTradingService.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticBot.Services.Alpaca; + +/// +/// Outcome of a confirmed Alpaca position liquidation (). +/// Only ever constructed after Alpaca's REST API has accepted the liquidation order — see the method's +/// XML doc for why callers may treat its mere existence as proof the broker confirmed the close. +/// +/// The Alpaca order ID of the liquidation (market) order. +/// The Alpaca order status returned immediately after submission (e.g. "Accepted", "Filled"). +/// +/// The average fill price if Alpaca already reports one at submission time; when the +/// liquidation order has been accepted but not yet filled (e.g. outside market hours). Callers must fall back +/// to the position's last known synced price in that case rather than treating as zero. +/// +public record AlpacaPositionCloseResult(string OrderId, string OrderStatus, decimal? AverageFillPrice); + +public interface IAlpacaTradingService +{ + bool IsConfigured { get; } + + Task PlaceBracketOrderAsync( + string symbol, + SignalDirection direction, + decimal quantity, + decimal entryPrice, + decimal stopLossPrice, + decimal takeProfitPrice, + CancellationToken cancellationToken = default); + + Task UpdateStopLossAsync( + string alpacaOrderId, + decimal newStopLossPrice, + CancellationToken cancellationToken = default); + + Task CancelOrderAsync( + string alpacaOrderId, + CancellationToken cancellationToken = default); + + /// + /// Liquidates the entire open position for at market price via Alpaca's native + /// position-close endpoint. Returns only once Alpaca has ACCEPTED the liquidation order — a caller (e.g. + /// the panic-close handler) must only mark the corresponding local position as closed AFTER this call + /// returns without throwing, never optimistically before calling it. + /// + /// Alpaca is not configured/reachable. + Task ClosePositionAsync( + string symbol, + CancellationToken cancellationToken = default); + + Task GetPortfolioSummaryAsync(CancellationToken cancellationToken = default); +} diff --git a/FinlyticBot/Services/AlpacaBrokerService.cs b/FinlyticBot/Services/AlpacaBrokerService.cs deleted file mode 100644 index 1dfcc52..0000000 --- a/FinlyticBot/Services/AlpacaBrokerService.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Alpaca.Markets; -using FinlyticBot.Util; -using FinlyticCore.Models.Trades; -using FinlyticCore.Services; -using Microsoft.Extensions.Configuration; - -namespace FinlyticBot.Services; - -public class AlpacaBrokerService : IAlpacaBrokerService -{ - private readonly ISettingsService _settingsService; - private readonly IConfiguration _configuration; - private readonly IFinlyticLogger _finlyticLogger; - - private IAlpacaTradingClient? _cachedClient; - private string _lastInitKey = string.Empty; - - public AlpacaBrokerService( - ISettingsService settingsService, - IConfiguration configuration, - IFinlyticLogger finlyticLogger) - { - _settingsService = settingsService; - _configuration = configuration; - _finlyticLogger = finlyticLogger; - } - - private async Task GetClientAsync(CancellationToken ct = default) - { - string keyId = await _settingsService.GetSettingAsync(SettingKeys.AlpacaKeyId, ct); - if (string.IsNullOrWhiteSpace(keyId)) - { - keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? string.Empty; - } - - string secretKey = await _settingsService.GetSettingAsync(SettingKeys.AlpacaSecretKey, ct); - if (string.IsNullOrWhiteSpace(secretKey)) - { - secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? string.Empty; - } - - bool isPaper = await _settingsService.GetSettingAsync(SettingKeys.AlpacaIsPaper, ct); - if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey)) - { - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, - "[AlpacaBroker] Alpaca API credentials (KeyId/SecretKey) are missing or empty."); - return null; - } - - string currentInitKey = $"{keyId}_{secretKey}_{isPaper}"; - if (_cachedClient != null && _lastInitKey == currentInitKey) - { - return _cachedClient; - } - - var environment = isPaper ? Alpaca.Markets.Environments.Paper : Alpaca.Markets.Environments.Live; - _cachedClient = environment.GetAlpacaTradingClient(new SecretKey(keyId, secretKey)); - _lastInitKey = currentInitKey; - - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaBroker] Initialized Alpaca Trading Client (Environment: {Env})", isPaper ? "Paper" : "Live"); - - return _cachedClient; - } - - public async Task GetAccountInfoAsync(CancellationToken ct = default) - { - var client = await GetClientAsync(ct); - if (client == null) return null; - - try - { - var account = await client.GetAccountAsync(ct); - return new BrokerAccountInfo( - Equity: account.Equity ?? 0m, - BuyingPower: account.BuyingPower ?? 0m, - Cash: account.TradableCash, - Currency: account.Currency ?? "USD", - IsBlocked: account.IsTradingBlocked - ); - } - catch (Exception ex) - { - await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex, - "[AlpacaBroker] Failed to fetch account information from Alpaca."); - return null; - } - } - - public async Task GetAssetAsync(string symbol, CancellationToken ct = default) - { - var client = await GetClientAsync(ct); - if (client == null) return null; - - try - { - return await client.GetAssetAsync(symbol, ct); - } - catch (Exception ex) - { - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, - "[AlpacaBroker] Asset {Symbol} not found or query error: {Msg}", symbol, ex.Message); - return null; - } - } - - public async Task GetMarketClockAsync(CancellationToken ct = default) - { - var client = await GetClientAsync(ct); - if (client == null) return null; - - try - { - return await client.GetClockAsync(ct); - } - catch (Exception ex) - { - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, - "[AlpacaBroker] Failed to query market clock: {Msg}", ex.Message); - return null; - } - } - - public async Task PlaceBracketOrderAsync( - TradeProposalDto proposal, - decimal quantity, - string orderType = "Limit", - CancellationToken ct = default) - { - var client = await GetClientAsync(ct); - if (client == null) return null; - - var side = string.Equals(proposal.SignalType, "SELL", StringComparison.OrdinalIgnoreCase) - ? OrderSide.Sell - : OrderSide.Buy; - - OrderQuantity orderQty = OrderQuantity.Fractional(quantity); - OrderBase baseOrder; - - if (string.Equals(orderType, "Market", StringComparison.OrdinalIgnoreCase)) - { - baseOrder = (side == OrderSide.Buy - ? MarketOrder.Buy(proposal.Symbol, orderQty) - : MarketOrder.Sell(proposal.Symbol, orderQty)) - .Bracket(proposal.TakeProfit, proposal.StopLoss); - } - else - { - baseOrder = (side == OrderSide.Buy - ? LimitOrder.Buy(proposal.Symbol, orderQty, proposal.EntryPrice) - : LimitOrder.Sell(proposal.Symbol, orderQty, proposal.EntryPrice)) - .Bracket(proposal.TakeProfit, proposal.StopLoss); - } - - baseOrder.Duration = TimeInForce.Gtc; - - try - { - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaBroker] Placing Bracket Order for {Symbol}: Side={Side}, Qty={Qty}, Entry=${Entry:F2}, SL=${SL:F2}, TP=${TP:F2}", - proposal.Symbol, side, quantity, proposal.EntryPrice, proposal.StopLoss, proposal.TakeProfit); - - var placedOrder = await client.PostOrderAsync(baseOrder, ct); - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaBroker] Bracket Order successfully placed with Alpaca! OrderId: {OrderId}, Status: {Status}", - placedOrder.OrderId, placedOrder.OrderStatus); - - return placedOrder; - } - catch (Exception ex) - { - await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex, - "[AlpacaBroker] Failed to place bracket order for {Symbol} on Alpaca.", proposal.Symbol); - return null; - } - } - - public async Task CancelOrderAsync(Guid orderId, CancellationToken ct = default) - { - var client = await GetClientAsync(ct); - if (client == null) return false; - - try - { - return await client.CancelOrderAsync(orderId, ct); - } - catch (Exception ex) - { - await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex, - "[AlpacaBroker] Failed to cancel order {OrderId} on Alpaca.", orderId); - return false; - } - } - - public async Task> GetOpenOrdersAsync(CancellationToken ct = default) - { - var client = await GetClientAsync(ct); - if (client == null) return Array.Empty(); - - try - { - var req = new ListOrdersRequest { OrderStatusFilter = OrderStatusFilter.Open }; - return await client.ListOrdersAsync(req, ct); - } - catch (Exception ex) - { - await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex, - "[AlpacaBroker] Failed to list open orders from Alpaca."); - return Array.Empty(); - } - } -} diff --git a/FinlyticBot/Services/AlpacaWebSocketMonitorWorker.cs b/FinlyticBot/Services/AlpacaWebSocketMonitorWorker.cs deleted file mode 100644 index 307a457..0000000 --- a/FinlyticBot/Services/AlpacaWebSocketMonitorWorker.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Alpaca.Markets; -using FinlyticBot.Database; -using FinlyticBot.Util; -using FinlyticCore.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace FinlyticBot.Services; - -public class AlpacaWebSocketMonitorWorker : BackgroundService -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ISettingsService _settingsService; - private readonly BotMqttClient _mqttClient; - private readonly IConfiguration _configuration; - private readonly IFinlyticLogger _finlyticLogger; - - private IAlpacaStreamingClient? _streamingClient; - - public AlpacaWebSocketMonitorWorker( - IServiceScopeFactory scopeFactory, - ISettingsService settingsService, - BotMqttClient mqttClient, - IConfiguration configuration, - IFinlyticLogger finlyticLogger) - { - _scopeFactory = scopeFactory; - _settingsService = settingsService; - _mqttClient = mqttClient; - _configuration = configuration; - _finlyticLogger = finlyticLogger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaWebSocketMonitor] Starting Alpaca Trade Update Stream Monitor..."); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - string keyId = await _settingsService.GetSettingAsync(SettingKeys.AlpacaKeyId, stoppingToken); - if (string.IsNullOrWhiteSpace(keyId)) - { - keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? string.Empty; - } - - string secretKey = await _settingsService.GetSettingAsync(SettingKeys.AlpacaSecretKey, stoppingToken); - if (string.IsNullOrWhiteSpace(secretKey)) - { - secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? string.Empty; - } - - bool isPaper = await _settingsService.GetSettingAsync(SettingKeys.AlpacaIsPaper, stoppingToken); - - if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey)) - { - await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); - continue; - } - - var environment = isPaper ? Alpaca.Markets.Environments.Paper : Alpaca.Markets.Environments.Live; - _streamingClient = environment.GetAlpacaStreamingClient(new SecretKey(keyId, secretKey)); - - _streamingClient.OnTradeUpdate += HandleTradeUpdate; - - var authStatus = await _streamingClient.ConnectAndAuthenticateAsync(stoppingToken); - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaWebSocketMonitor] Connected & Authenticated to Alpaca Streaming WS. Status: {Status}", authStatus.ToString()); - - // Keep connection alive until cancellation - var tcs = new TaskCompletionSource(); - using (stoppingToken.Register(() => tcs.TrySetResult(true))) - { - await tcs.Task; - } - - await _streamingClient.DisconnectAsync(CancellationToken.None); - } - catch (Exception ex) when (!stoppingToken.IsCancellationRequested) - { - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, ex, - "[AlpacaWebSocketMonitor] Streaming WebSocket disconnected. Retrying in 10s..."); - await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); - } - } - } - - private void HandleTradeUpdate(ITradeUpdate update) - { - _ = Task.Run(async () => - { - try - { - using var scope = _scopeFactory.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var order = update.Order; - if (order == null) return; - - var trade = await dbContext.ExecutedPaperTrades - .FirstOrDefaultAsync(t => t.AlpacaOrderId == order.OrderId); - - if (trade == null) - { - // Check if it's a child order (SL / TP) of an existing trade - trade = await dbContext.ExecutedPaperTrades - .Where(t => t.Symbol == order.Symbol && t.Status == "Filled") - .OrderByDescending(t => t.PlacedAt) - .FirstOrDefaultAsync(); - } - - if (trade == null) return; - - if (update.Event == TradeEvent.Fill) - { - decimal fillPrice = update.Price ?? order.AverageFillPrice ?? trade.SignalEntryPrice; - trade.ActualFillPrice = fillPrice; - trade.Status = "Filled"; - trade.FilledAt = DateTime.UtcNow; - - if (trade.SignalEntryPrice > 0) - { - trade.SlippagePercent = Math.Round(((fillPrice - trade.SignalEntryPrice) / trade.SignalEntryPrice) * 100m, 3); - } - - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaTradeUpdate] Order FILLED for {Symbol}: FillPrice=${Price:F2} (Signal: ${SigPrice:F2}, Slippage: {Slip:F3}%)", - trade.Symbol, fillPrice, trade.SignalEntryPrice, trade.SlippagePercent ?? 0m); - } - else if (update.Event == TradeEvent.PartialFill) - { - trade.Status = "PartiallyFilled"; - } - else if (update.Event == TradeEvent.Canceled || update.Event == TradeEvent.Expired || update.Event == TradeEvent.Rejected) - { - trade.Status = update.Event.ToString(); - trade.ClosedAt = DateTime.UtcNow; - } - else if (update.Event == TradeEvent.Stopped || update.Event == TradeEvent.Calculated) - { - // Position closed by Stop Loss or Take Profit - trade.Status = "Closed"; - trade.ClosedAt = DateTime.UtcNow; - - decimal exitPrice = update.Price ?? trade.ActualFillPrice ?? trade.SignalEntryPrice; - if (trade.ActualFillPrice.HasValue && update.Price.HasValue) - { - exitPrice = update.Price.Value; - decimal diff = trade.Side == "BUY" ? (exitPrice - trade.ActualFillPrice.Value) : (trade.ActualFillPrice.Value - exitPrice); - trade.RealizedPnl = diff * trade.Quantity; - if (trade.ActualFillPrice.Value > 0) - { - trade.RealizedPnlPercent = Math.Round((diff / trade.ActualFillPrice.Value) * 100m, 2); - } - } - - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaTradeUpdate] Position CLOSED for {Symbol}: Realized PnL: ${Pnl:F2} ({Pct:F2}%)", - trade.Symbol, trade.RealizedPnl, trade.RealizedPnlPercent ?? 0m); - - // Publish Closed Trade to MQTT for WinRate calibration & AI feedback loop - bool isWin = trade.RealizedPnl > 0; - var feedbackDto = new FinlyticCore.Models.Trades.TradeProposalDto - { - TradeId = trade.TradeId, - Symbol = trade.Symbol, - Isin = trade.Isin, - CompanyName = trade.CompanyName, - EntryPrice = trade.SignalEntryPrice, - ActualEntryPrice = trade.ActualFillPrice, - CurrentPrice = exitPrice, - StopLoss = trade.StopLossPrice, - TakeProfit = trade.TakeProfitPrice1, - Status = isWin ? "Closed_Profit" : "Closed_Loss", - SignalType = trade.Side, - WinRate = trade.WinRate, - PnlAbsolute = trade.RealizedPnl, - PnlPercent = trade.RealizedPnlPercent, - CloseReason = isWin ? "TakeProfit_Hit" : "StopLoss_Hit", - UserExitTimestamp = trade.ClosedAt, - CreatedAt = trade.PlacedAt - }; - - await _mqttClient.PublishAsync($"finlytic/trades/closed/{trade.TradeId}", feedbackDto); - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[AlpacaTradeUpdate] Dispatched closed trade feedback event to MQTT for {TradeId} (Win: {IsWin})", - trade.TradeId, isWin); - } - - trade.UpdatedAt = DateTime.UtcNow; - await dbContext.SaveChangesAsync(); - } - catch (Exception ex) - { - _ = _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex, - "[AlpacaWebSocketMonitor] Error processing TradeUpdate event."); - } - }); - } -} diff --git a/FinlyticBot/Services/BotOrderExecutionWorker.cs b/FinlyticBot/Services/BotOrderExecutionWorker.cs deleted file mode 100644 index 0f21937..0000000 --- a/FinlyticBot/Services/BotOrderExecutionWorker.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Alpaca.Markets; -using FinlyticBot.Database; -using FinlyticBot.Entities; -using FinlyticBot.Util; -using FinlyticCore.Models.Trades; -using FinlyticCore.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace FinlyticBot.Services; - -public interface IBotOrderExecutionService -{ - Task ProcessTradeProposalAsync(TradeProposalDto proposal, CancellationToken ct = default); -} - -public class BotOrderExecutionService : IBotOrderExecutionService -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly IAlpacaBrokerService _brokerService; - private readonly IBotRiskSizingService _sizingService; - private readonly ISettingsService _settingsService; - private readonly IFinlyticLogger _finlyticLogger; - - public BotOrderExecutionService( - IServiceScopeFactory scopeFactory, - IAlpacaBrokerService brokerService, - IBotRiskSizingService sizingService, - ISettingsService settingsService, - IFinlyticLogger finlyticLogger) - { - _scopeFactory = scopeFactory; - _brokerService = brokerService; - _sizingService = sizingService; - _settingsService = settingsService; - _finlyticLogger = finlyticLogger; - } - - public async Task ProcessTradeProposalAsync(TradeProposalDto proposal, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(proposal); - - if (string.IsNullOrWhiteSpace(proposal.Symbol)) - { - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, - "[BotExecution] Trade proposal has no valid Symbol. Skipping."); - return; - } - - using var scope = _scopeFactory.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - // 1. Check if trade was already processed - bool alreadyExists = await dbContext.ExecutedPaperTrades - .AnyAsync(t => t.TradeId == proposal.TradeId, ct); - if (alreadyExists) - { - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[BotExecution] Trade proposal {TradeId} ({Symbol}) already processed. Skipping duplicate.", - proposal.TradeId, proposal.Symbol); - return; - } - - // 2. Validate asset on Alpaca - var asset = await _brokerService.GetAssetAsync(proposal.Symbol, ct); - if (asset == null || !asset.IsTradable) - { - string reason = $"Asset '{proposal.Symbol}' is not tradeable on Alpaca."; - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, - "[BotExecution] Trade {TradeId} ({Symbol}) rejected: {Reason}", proposal.TradeId, proposal.Symbol, reason); - - await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, reason, 0, 0, ct); - return; - } - - // 3. Query Account Equity from Alpaca - var account = await _brokerService.GetAccountInfoAsync(ct); - if (account == null) - { - string reason = "Failed to query account information from Alpaca API."; - await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, reason, 0, 0, ct); - return; - } - - if (account.IsBlocked) - { - string reason = "Alpaca trading account is currently blocked."; - await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, reason, 0, account.Equity, ct); - return; - } - - // 4. Calculate stats (current open trades and today's loss) - int openTradesCount = await dbContext.ExecutedPaperTrades - .CountAsync(t => t.Status == "Submitted" || t.Status == "Filled" || t.Status == "PartiallyFilled", ct); - - var todayUtc = DateTime.UtcNow.Date; - var todayClosedTrades = await dbContext.ExecutedPaperTrades - .Where(t => t.ClosedAt >= todayUtc && t.Status == "Closed") - .ToListAsync(ct); - - decimal todayRealizedLoss = todayClosedTrades.Where(t => t.RealizedPnl < 0).Sum(t => Math.Abs(t.RealizedPnl)); - decimal todayLossPercent = account.Equity > 0 ? (todayRealizedLoss / account.Equity) * 100m : 0m; - - // 5. Evaluate through Risk & Sizing Engine - var sizing = await _sizingService.EvaluateAndSizeTradeAsync( - proposal, - account.Equity, - openTradesCount, - todayLossPercent, - ct - ); - - if (!sizing.IsApproved) - { - await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, sizing.RejectReason, 0, account.Equity, ct); - return; - } - - // 6. Submit Order to Alpaca - string orderType = await _settingsService.GetSettingAsync(SettingKeys.ExecutionOrderType, ct); - var placedOrder = await _brokerService.PlaceBracketOrderAsync(proposal, sizing.Quantity, orderType, ct); - - if (placedOrder == null) - { - string reason = "Alpaca API rejected bracket order placement."; - await RecordAuditLogAsync(dbContext, proposal, "OrderFailed", false, reason, sizing.Quantity, account.Equity, ct); - return; - } - - // 7. Persist Executed Paper Trade - var executedTrade = new ExecutedPaperTradeEntity - { - TradeId = proposal.TradeId, - Symbol = proposal.Symbol, - Isin = proposal.Isin, - CompanyName = proposal.CompanyName, - AlpacaOrderId = placedOrder.OrderId, - Side = string.Equals(proposal.SignalType, "SELL", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", - Quantity = sizing.Quantity, - SignalEntryPrice = proposal.EntryPrice, - StopLossPrice = proposal.StopLoss, - TakeProfitPrice1 = proposal.TakeProfit, - TakeProfitPrice2 = proposal.TakeProfitTargets != null && proposal.TakeProfitTargets.Count > 1 ? proposal.TakeProfitTargets[1] : null, - CalculatedCrv = sizing.CalculatedCrv, - WinRate = proposal.WinRate, - Status = placedOrder.OrderStatus == OrderStatus.Filled ? "Filled" : "Submitted", - PlacedAt = DateTime.UtcNow, - FilledAt = placedOrder.OrderStatus == OrderStatus.Filled ? DateTime.UtcNow : null, - ActualFillPrice = placedOrder.AverageFillPrice ?? (placedOrder.OrderStatus == OrderStatus.Filled ? proposal.EntryPrice : null) - }; - - dbContext.ExecutedPaperTrades.Add(executedTrade); - await RecordAuditLogAsync(dbContext, proposal, "OrderPlaced", true, $"Bracket Order placed with Alpaca. OrderId: {placedOrder.OrderId}", sizing.Quantity, account.Equity, ct); - - await dbContext.SaveChangesAsync(ct); - - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[BotExecution] Trade {Symbol} successfully recorded in database with Alpaca OrderId {OrderId}.", - proposal.Symbol, placedOrder.OrderId); - } - - private static async Task RecordAuditLogAsync( - BotDbContext dbContext, - TradeProposalDto proposal, - string action, - bool isAccepted, - string? reason, - decimal calculatedSize, - decimal accountEquity, - CancellationToken ct) - { - var audit = new BotAuditLogEntity - { - TradeId = proposal.TradeId, - Symbol = proposal.Symbol, - Action = action, - IsAccepted = isAccepted, - Reason = reason, - CalculatedSize = calculatedSize, - AccountEquity = accountEquity, - Timestamp = DateTime.UtcNow - }; - - dbContext.BotAuditLogs.Add(audit); - await dbContext.SaveChangesAsync(ct); - } -} diff --git a/FinlyticBot/Services/BotRiskSizingService.cs b/FinlyticBot/Services/BotRiskSizingService.cs deleted file mode 100644 index 71a847b..0000000 --- a/FinlyticBot/Services/BotRiskSizingService.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using FinlyticBot.Util; -using FinlyticCore.Models.Trades; -using FinlyticCore.Services; - -namespace FinlyticBot.Services; - -public class BotRiskSizingService : IBotRiskSizingService -{ - private readonly ISettingsService _settingsService; - private readonly IFinlyticLogger _finlyticLogger; - - public BotRiskSizingService( - ISettingsService settingsService, - IFinlyticLogger finlyticLogger) - { - _settingsService = settingsService; - _finlyticLogger = finlyticLogger; - } - - public async Task EvaluateAndSizeTradeAsync( - TradeProposalDto proposal, - decimal accountEquity, - int currentOpenTradesCount, - decimal todayRealizedLossPercent, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(proposal); - - // 1. Check Master Bot Switch - bool isEnabled = await _settingsService.GetSettingAsync(SettingKeys.IsEnabled, ct); - if (!isEnabled) - { - return new SizingResult(false, "FinlyticBot is currently disabled in settings.", 0, 0, 0, 0); - } - - // 2. Check Daily Drawdown Circuit Breaker - double dailyLossLimit = await _settingsService.GetSettingAsync(SettingKeys.DailyLossLimitPercent, ct); - if (todayRealizedLossPercent >= (decimal)dailyLossLimit) - { - await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, - "[RiskEngine] Circuit breaker triggered! Today's realized loss {Loss:F2}% >= limit {Limit:F2}%. Rejecting trade {TradeId}.", - todayRealizedLossPercent, dailyLossLimit, proposal.TradeId); - return new SizingResult(false, $"Daily loss limit reached ({todayRealizedLossPercent:F2}% >= {dailyLossLimit:F2}%).", 0, 0, 0, 0); - } - - // 3. Check Max Open Trades Limit - int maxOpenTrades = await _settingsService.GetSettingAsync(SettingKeys.MaxOpenTrades, ct); - if (currentOpenTradesCount >= maxOpenTrades) - { - return new SizingResult(false, $"Max concurrent open positions reached ({currentOpenTradesCount}/{maxOpenTrades}).", 0, 0, 0, 0); - } - - // 4. Validate CRV (Chance-Risiko-Verhältnis) - double minCrv = await _settingsService.GetSettingAsync(SettingKeys.MinCrv, ct); - decimal calculatedCrv = proposal.RiskRewardRatio ?? 0; - if (calculatedCrv <= 0 && proposal.EntryPrice > 0 && proposal.StopLoss > 0 && proposal.TakeProfit > 0) - { - decimal slDist = Math.Abs(proposal.EntryPrice - proposal.StopLoss); - decimal tpDist = Math.Abs(proposal.TakeProfit - proposal.EntryPrice); - if (slDist > 0) - { - calculatedCrv = Math.Round(tpDist / slDist, 2); - } - } - - if (calculatedCrv < (decimal)minCrv) - { - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[RiskEngine] Trade {Symbol} rejected: CRV {Crv:F2} below threshold {MinCrv:F2}", - proposal.Symbol, calculatedCrv, minCrv); - return new SizingResult(false, $"CRV {calculatedCrv:F2} below minimum threshold {minCrv:F2}.", 0, 0, 0, calculatedCrv); - } - - // 5. Validate Win-Rate - double minWinRate = await _settingsService.GetSettingAsync(SettingKeys.MinWinRate, ct); - if (proposal.WinRate < minWinRate) - { - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[RiskEngine] Trade {Symbol} rejected: Win-Rate {WinRate:F1}% below threshold {MinWinRate:F1}%", - proposal.Symbol, proposal.WinRate, minWinRate); - return new SizingResult(false, $"Win-Rate {proposal.WinRate:F1}% below minimum threshold {minWinRate:F1}%.", 0, 0, 0, calculatedCrv); - } - - // 6. Validate VIX Threshold - double maxVix = await _settingsService.GetSettingAsync(SettingKeys.MaxVixThreshold, ct); - if (proposal.VixValue > (decimal)maxVix) - { - return new SizingResult(false, $"VIX {proposal.VixValue:F1} exceeds maximum volatility threshold {maxVix:F1}.", 0, 0, 0, calculatedCrv); - } - - // 7. Calculate Position Sizing (Fixed Fractional Sizing) - if (accountEquity <= 0) - { - return new SizingResult(false, "Account equity is zero or negative.", 0, 0, 0, calculatedCrv); - } - - double riskPercent = await _settingsService.GetSettingAsync(SettingKeys.RiskPerTradePercent, ct); - decimal maxRiskAmount = accountEquity * ((decimal)riskPercent / 100m); - decimal priceRiskPerUnit = Math.Abs(proposal.EntryPrice - proposal.StopLoss); - - if (priceRiskPerUnit <= 0) - { - return new SizingResult(false, "Stop loss cannot be identical to entry price.", 0, 0, 0, calculatedCrv); - } - - decimal calculatedQty = Math.Floor(maxRiskAmount / priceRiskPerUnit); - if (calculatedQty <= 0) - { - // Allow fractional share if total position is at least 10$ - calculatedQty = Math.Round(maxRiskAmount / priceRiskPerUnit, 2); - if (calculatedQty <= 0) - { - return new SizingResult(false, "Calculated order quantity is 0 (account equity too small for Stop Loss distance).", 0, 0, 0, calculatedCrv); - } - } - - decimal totalPositionValue = calculatedQty * proposal.EntryPrice; - - // 8. Cap against Max Single Position Cap - double maxCap = await _settingsService.GetSettingAsync(SettingKeys.MaxSinglePositionCap, ct); - if (totalPositionValue > (decimal)maxCap && proposal.EntryPrice > 0) - { - calculatedQty = Math.Floor((decimal)maxCap / proposal.EntryPrice); - totalPositionValue = calculatedQty * proposal.EntryPrice; - if (calculatedQty <= 0) - { - return new SizingResult(false, "Position size exceeds maximum position cap.", 0, 0, 0, calculatedCrv); - } - } - - decimal actualRiskAmount = calculatedQty * priceRiskPerUnit; - - await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, - "[RiskEngine] Sizing APPROVED for {Symbol}: Qty={Qty}, PositionVal=${PosVal:F2}, Risk=${Risk:F2} ({RiskPct:F1}%), CRV={Crv:F2}", - proposal.Symbol, calculatedQty, totalPositionValue, actualRiskAmount, riskPercent, calculatedCrv); - - return new SizingResult(true, null, calculatedQty, totalPositionValue, actualRiskAmount, calculatedCrv); - } -} diff --git a/FinlyticBot/Services/Consumers/EngineProposalConsumerBackgroundService.cs b/FinlyticBot/Services/Consumers/EngineProposalConsumerBackgroundService.cs new file mode 100644 index 0000000..f9d883f --- /dev/null +++ b/FinlyticBot/Services/Consumers/EngineProposalConsumerBackgroundService.cs @@ -0,0 +1,66 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticBot.Services.Execution; +using FinlyticBot.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FinlyticBot.Services.Consumers; + +public class EngineProposalConsumerBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public static Action? OnProposalReceived; + + public EngineProposalConsumerBackgroundService( + IServiceScopeFactory scopeFactory, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _settingsService = settingsService; + _logger = logger; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + OnProposalReceived = async (proposal) => + { + if (stoppingToken.IsCancellationRequested) return; + + try + { + var autoExec = await _settingsService.GetSettingAsync(BotSettingKeys.EnableAutoExecution, stoppingToken); + if (!autoExec) + { + await _logger.LogInfoAsync(BotSettingKeys.BotChannel, + "[ProposalConsumer] Auto-execution is disabled. Ignoring proposal {Id} for {Isin}.", + proposal.ProposalId, proposal.UnderlyingIsin); + return; + } + + await _logger.LogInfoAsync(BotSettingKeys.BotChannel, + "[ProposalConsumer] Consumed approved proposal {Id} for {Isin}. Executing paper trade...", + proposal.ProposalId, proposal.UnderlyingIsin); + + using var scope = _scopeFactory.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.ExecuteProposalAsync(proposal, cancellationToken: stoppingToken); + } + catch (Exception ex) + { + await _logger.LogErrorAsync(BotSettingKeys.BotChannel, ex, + "[ProposalConsumer] Error executing trade proposal {Id}", proposal.ProposalId); + } + }; + + return Task.CompletedTask; + } +} diff --git a/FinlyticBot/Services/Execution/BotOrderExecutor.cs b/FinlyticBot/Services/Execution/BotOrderExecutor.cs new file mode 100644 index 0000000..59861de --- /dev/null +++ b/FinlyticBot/Services/Execution/BotOrderExecutor.cs @@ -0,0 +1,241 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticBot.Database; +using FinlyticBot.Database.Entities; +using FinlyticBot.Services.Alpaca; +using FinlyticBot.Services.Ledger; +using FinlyticBot.Settings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FinlyticBot.Services.Execution; + +public class BotOrderExecutor : IBotOrderExecutor +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IAlpacaTradingService _alpacaService; + private readonly ISyntheticPaperBroker _syntheticBroker; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public BotOrderExecutor( + IServiceScopeFactory scopeFactory, + IAlpacaTradingService alpacaService, + ISyntheticPaperBroker syntheticBroker, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _alpacaService = alpacaService; + _syntheticBroker = syntheticBroker; + _settingsService = settingsService; + _logger = logger; + } + + public async Task ExecuteProposalAsync( + TradeProposalDto proposal, + BotExecutionVenue? preferredVenue = null, + decimal? customQuantity = null, + CancellationToken cancellationToken = default) + { + if (proposal == null || string.IsNullOrWhiteSpace(proposal.UnderlyingIsin)) return null; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // 1. Risk Gate: Check active positions count + int maxPositions = await _settingsService.GetSettingAsync(BotSettingKeys.MaxConcurrentPositions, cancellationToken); + int activeCount = await db.Positions.CountAsync( + p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered, + cancellationToken); + + if (activeCount >= maxPositions) + { + await _logger.LogWarningAsync(BotSettingKeys.BotChannel, + "[BotExecutor] Risk Gate rejected proposal {ProposalId}: Max concurrent positions ({Max}) reached (Active: {Active}).", + proposal.ProposalId, maxPositions, activeCount); + return null; + } + + // 2. Risk Gate: Calculate dynamic sizing (1-2% Rule based on Account Equity and Stop-Loss distance) + decimal riskPerTradePct = await _settingsService.GetSettingAsync(BotSettingKeys.RiskPerTradePercent, cancellationToken); + if (riskPerTradePct <= 0m) riskPerTradePct = 1.0m; + + decimal maxAllocationPct = await _settingsService.GetSettingAsync(BotSettingKeys.MaxPositionAllocationPercent, cancellationToken); + if (maxAllocationPct <= 0m) maxAllocationPct = 20.0m; + + // Fetch current total account equity (fällt auf das konfigurierte synthetische Startkapital + // zurück, falls der Ledger-Abruf fehlschlägt - dieselbe Quelle wie SyntheticPaperBroker.GetSummaryAsync). + decimal totalEquity = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken); + try + { + var summary = await _syntheticBroker.GetSummaryAsync(cancellationToken); + if (summary?.Equity > 0) + { + totalEquity = summary.Equity; + } + } + catch (DbException ex) + { + await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex, + "[BotExecutor] Failed to fetch synthetic ledger summary from database. Falling back to configured base capital ({BaseCapital:F2} €).", + totalEquity); + } + + decimal maxRiskCapital = totalEquity * (riskPerTradePct / 100.0m); + decimal maxPositionCapital = totalEquity * (maxAllocationPct / 100.0m); + + decimal quantity = customQuantity ?? 1m; + if (!customQuantity.HasValue && proposal.EntryPrice > 0) + { + decimal unitRisk = Math.Abs(proposal.EntryPrice - proposal.InvalidationPrice); + if (unitRisk > 0) + { + // Dynamic 1-2% rule: Quantity = MaxRiskCapital / UnitRisk + decimal calculatedQty = maxRiskCapital / unitRisk; + + // Safeguard: Never allocate more than maxPositionCapital to a single position + decimal maxQtyByCapital = maxPositionCapital / proposal.EntryPrice; + if (calculatedQty > maxQtyByCapital) + { + calculatedQty = maxQtyByCapital; + } + + quantity = Math.Max(1m, Math.Round(calculatedQty, 0)); + + await _logger.LogInfoAsync(BotSettingKeys.BotChannel, + "[BotExecutor] Dynamic Sizing (1-2% Rule): Equity={Equity:F2} €, RiskPct={RiskPct}%, MaxRisk={RiskCap:F2} €, UnitRisk={UnitRisk:F2} € => Quantity={Qty} (Max Alloc: {MaxCap:F2} €)", + totalEquity, riskPerTradePct, maxRiskCapital, unitRisk, quantity, maxPositionCapital); + } + else + { + // Fallback if stop loss is invalid: allocate 5% of equity + decimal fallbackCapital = totalEquity * 0.05m; + quantity = Math.Max(1m, Math.Round(fallbackCapital / proposal.EntryPrice, 0)); + } + } + + // 3. Venue Decision + BotExecutionVenue venue = preferredVenue ?? BotExecutionVenue.SyntheticPaperBroker; + bool isUsEquities = proposal.UnderlyingIsin.StartsWith("US", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(proposal.Symbol); + + if (!preferredVenue.HasValue) + { + venue = (isUsEquities && _alpacaService.IsConfigured && proposal.SelectedDerivative == null) + ? BotExecutionVenue.AlpacaPaperTrading + : BotExecutionVenue.SyntheticPaperBroker; + } + + decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0 + ? proposal.ExitPlan.TakeProfitStages[0].TargetPrice + : (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.05m : proposal.EntryPrice * 0.95m); + + decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 + ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice + : (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.10m : proposal.EntryPrice * 0.90m); + + BotPositionEntity positionEntity; + + if (venue == BotExecutionVenue.AlpacaPaperTrading) + { + try + { + string alpacaOrderId = await _alpacaService.PlaceBracketOrderAsync( + proposal.Symbol, + proposal.Direction, + (int)quantity, + proposal.EntryPrice, + proposal.InvalidationPrice, + takeProfit1, + cancellationToken + ); + + positionEntity = new BotPositionEntity + { + Id = Guid.NewGuid(), + ProposalId = proposal.ProposalId, + Isin = proposal.UnderlyingIsin, + Symbol = proposal.Symbol, + Venue = BotExecutionVenue.AlpacaPaperTrading, + AlpacaOrderId = alpacaOrderId, + ClientOrderId = $"ALP_{Guid.NewGuid():N}", + Direction = proposal.Direction, + Quantity = quantity, + EntryPrice = proposal.EntryPrice, + AverageBuyIn = proposal.EntryPrice, + InitialStopLoss = proposal.InvalidationPrice, + CurrentStopLoss = proposal.InvalidationPrice, + CurrentPrice = proposal.EntryPrice, + TakeProfit1 = takeProfit1, + TakeProfit2 = takeProfit2, + TotalFeesEur = 0m, // Alpaca zero commission paper + RealizedPnlEur = 0m, + Status = BotPositionStatus.Active, + ExitPlan = proposal.ExitPlan, + OpenedAtUtc = DateTime.UtcNow, + LastSyncAtUtc = DateTime.UtcNow + }; + + db.Positions.Add(positionEntity); + await db.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex, + "[BotExecutor] Alpaca order placement failed for {Symbol}. Falling back to Synthetic Broker.", proposal.Symbol); + positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken); + } + } + else + { + positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken); + } + + return MapEntityToDto(positionEntity); + } + + public static BotTradeOrderDto MapEntityToDto(BotPositionEntity e) + { + decimal unrealizedPnl = 0m; + if (e.AverageBuyIn > 0 && e.Quantity > 0 && e.CurrentPrice > 0) + { + unrealizedPnl = e.Direction == SignalDirection.Buy + ? (e.CurrentPrice - e.AverageBuyIn) * e.Quantity + : (e.AverageBuyIn - e.CurrentPrice) * e.Quantity; + } + + return new BotTradeOrderDto( + OrderId: e.Id, + ProposalId: e.ProposalId, + Isin: e.Isin, + Symbol: e.Symbol, + Venue: e.Venue, + AlpacaOrderId: e.AlpacaOrderId, + ClientOrderId: e.ClientOrderId, + Direction: e.Direction, + RequestedQuantity: e.Quantity, + FilledQuantity: e.Quantity, + EntryPrice: e.EntryPrice, + AverageBuyIn: e.AverageBuyIn, + InitialStopLoss: e.InitialStopLoss, + CurrentStopLoss: e.CurrentStopLoss, + TakeProfit1: e.TakeProfit1, + TakeProfit2: e.TakeProfit2, + CurrentPrice: e.CurrentPrice, + UnrealizedPnlEur: Math.Round(unrealizedPnl, 2), + RealizedPnlEur: e.RealizedPnlEur, + Status: e.Status, + ExitPlan: e.ExitPlan, + CreatedAtUtc: e.OpenedAtUtc, + FilledAtUtc: e.OpenedAtUtc, + ClosedAtUtc: e.ClosedAtUtc + ); + } +} diff --git a/FinlyticBot/Services/Execution/IBotOrderExecutor.cs b/FinlyticBot/Services/Execution/IBotOrderExecutor.cs new file mode 100644 index 0000000..c7cf9ad --- /dev/null +++ b/FinlyticBot/Services/Execution/IBotOrderExecutor.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.Trading; + +namespace FinlyticBot.Services.Execution; + +public interface IBotOrderExecutor +{ + Task ExecuteProposalAsync( + TradeProposalDto proposal, + BotExecutionVenue? preferredVenue = null, + decimal? customQuantity = null, + CancellationToken cancellationToken = default); +} diff --git a/FinlyticBot/Services/IAlpacaBrokerService.cs b/FinlyticBot/Services/IAlpacaBrokerService.cs deleted file mode 100644 index 4b4e04a..0000000 --- a/FinlyticBot/Services/IAlpacaBrokerService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Alpaca.Markets; -using FinlyticCore.Models.Trades; - -namespace FinlyticBot.Services; - -public record BrokerAccountInfo( - decimal Equity, - decimal BuyingPower, - decimal Cash, - string Currency, - bool IsBlocked -); - -public interface IAlpacaBrokerService -{ - Task GetAccountInfoAsync(CancellationToken ct = default); - Task GetAssetAsync(string symbol, CancellationToken ct = default); - Task GetMarketClockAsync(CancellationToken ct = default); - Task PlaceBracketOrderAsync(TradeProposalDto proposal, decimal quantity, string orderType = "Limit", CancellationToken ct = default); - Task CancelOrderAsync(Guid orderId, CancellationToken ct = default); - Task> GetOpenOrdersAsync(CancellationToken ct = default); -} diff --git a/FinlyticBot/Services/IBotRiskSizingService.cs b/FinlyticBot/Services/IBotRiskSizingService.cs deleted file mode 100644 index ef3d5f4..0000000 --- a/FinlyticBot/Services/IBotRiskSizingService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using FinlyticCore.Models.Trades; - -namespace FinlyticBot.Services; - -public record SizingResult( - bool IsApproved, - string? RejectReason, - decimal Quantity, - decimal TotalPositionValue, - decimal RiskAmount, - decimal CalculatedCrv -); - -public interface IBotRiskSizingService -{ - Task EvaluateAndSizeTradeAsync( - TradeProposalDto proposal, - decimal accountEquity, - int currentOpenTradesCount, - decimal todayRealizedLossPercent, - CancellationToken ct = default); -} diff --git a/FinlyticBot/Services/Ledger/ISyntheticPaperBroker.cs b/FinlyticBot/Services/Ledger/ISyntheticPaperBroker.cs new file mode 100644 index 0000000..a9a468c --- /dev/null +++ b/FinlyticBot/Services/Ledger/ISyntheticPaperBroker.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.Trading; +using FinlyticBot.Database.Entities; + +namespace FinlyticBot.Services.Ledger; + +public interface ISyntheticPaperBroker +{ + Task OpenPositionAsync( + TradeProposalDto proposal, + decimal quantity, + CancellationToken cancellationToken = default); + + Task ClosePositionAsync( + Guid positionId, + decimal exitPrice, + BotPositionStatus exitStatus, + CancellationToken cancellationToken = default); + + Task GetSummaryAsync(CancellationToken cancellationToken = default); +} diff --git a/FinlyticBot/Services/Ledger/SyntheticPaperBroker.cs b/FinlyticBot/Services/Ledger/SyntheticPaperBroker.cs new file mode 100644 index 0000000..ce3a543 --- /dev/null +++ b/FinlyticBot/Services/Ledger/SyntheticPaperBroker.cs @@ -0,0 +1,167 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticBot.Database; +using FinlyticBot.Database.Entities; +using FinlyticBot.Settings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FinlyticBot.Services.Ledger; + +public class SyntheticPaperBroker : ISyntheticPaperBroker +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public SyntheticPaperBroker( + IServiceScopeFactory scopeFactory, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _settingsService = settingsService; + _logger = logger; + } + + public async Task OpenPositionAsync( + TradeProposalDto proposal, + decimal quantity, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + decimal entryPrice = proposal.EntryPrice; + decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0 + ? proposal.ExitPlan.TakeProfitStages[0].TargetPrice + : (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.05m : entryPrice * 0.95m); + + decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 + ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice + : (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.10m : entryPrice * 0.90m); + + var position = new BotPositionEntity + { + Id = Guid.NewGuid(), + ProposalId = proposal.ProposalId, + Isin = proposal.UnderlyingIsin, + Symbol = proposal.Symbol, + Venue = BotExecutionVenue.SyntheticPaperBroker, + ClientOrderId = $"SYN_{Guid.NewGuid():N}", + Direction = proposal.Direction, + Quantity = quantity, + EntryPrice = entryPrice, + AverageBuyIn = entryPrice, + InitialStopLoss = proposal.InvalidationPrice, + CurrentStopLoss = proposal.InvalidationPrice, + CurrentPrice = entryPrice, + TakeProfit1 = takeProfit1, + TakeProfit2 = takeProfit2, + TotalFeesEur = 1.00m, + RealizedPnlEur = 0m, + Status = BotPositionStatus.Active, + ExitPlan = proposal.ExitPlan, + OpenedAtUtc = DateTime.UtcNow, + LastSyncAtUtc = DateTime.UtcNow + }; + + db.Positions.Add(position); + await db.SaveChangesAsync(cancellationToken); + + await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel, + "[SyntheticBroker] Opened position {Id} for {Isin} ({Symbol}) at {Entry:F2} € (Qty: {Qty}, SL: {SL:F2}, TP1: {TP1:F2})", + position.Id, position.Isin, position.Symbol, position.EntryPrice, position.Quantity, position.CurrentStopLoss, position.TakeProfit1); + + return position; + } + + public async Task ClosePositionAsync( + Guid positionId, + decimal exitPrice, + BotPositionStatus exitStatus, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var pos = await db.Positions.FirstOrDefaultAsync(p => p.Id == positionId, cancellationToken); + if (pos == null) throw new InvalidOperationException($"Position {positionId} not found."); + + pos.Status = exitStatus; + pos.ClosedAtUtc = DateTime.UtcNow; + pos.CurrentPrice = exitPrice; + pos.LastSyncAtUtc = DateTime.UtcNow; + pos.TotalFeesEur += 1.00m; // Exit fee + + if (exitStatus == BotPositionStatus.KnockedOut) + { + pos.RealizedPnlEur = -((pos.AverageBuyIn * pos.Quantity) + pos.TotalFeesEur); + } + else + { + decimal pnl = pos.Direction == SignalDirection.Buy + ? ((exitPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur + : ((pos.AverageBuyIn - exitPrice) * pos.Quantity) - pos.TotalFeesEur; + + pos.RealizedPnlEur = Math.Round(pnl, 2); + } + + await db.SaveChangesAsync(cancellationToken); + + await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel, + "[SyntheticBroker] Closed position {Id} at {Exit:F2} € with status {Status} (PnL: {PnL:F2} €)", + pos.Id, exitPrice, exitStatus, pos.RealizedPnlEur); + + return pos; + } + + public async Task GetSummaryAsync(CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken); + var positions = await db.Positions.AsNoTracking().ToListAsync(cancellationToken); + + decimal totalRealized = positions.Sum(p => p.RealizedPnlEur); + decimal totalFees = positions.Sum(p => p.TotalFeesEur); + + var openPositions = positions + .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered) + .ToList(); + + // Unrealized P&L of still-open positions (direction-aware: a short position gains when + // CurrentPrice drops below AverageBuyIn). CurrentPrice is kept fresh by + // BotTradeLifecycleBackgroundService, which re-fetches the latest candle close for every open + // position on each monitoring tick. Without this term, equity only ever moved when a position + // closed, even though open positions were already sitting on real gains/losses. + decimal unrealizedPnl = openPositions.Sum(p => p.Direction == SignalDirection.Buy + ? (p.CurrentPrice - p.AverageBuyIn) * p.Quantity + : (p.AverageBuyIn - p.CurrentPrice) * p.Quantity); + + decimal currentEquity = baseCapital + totalRealized + unrealizedPnl; + decimal invested = openPositions.Sum(p => p.AverageBuyIn * p.Quantity); + + // Cash is equity minus the capital tied up in open positions at cost (AverageBuyIn), i.e. the + // portion of the ledger not currently committed to a position - unrealized gains/losses on open + // positions are reflected in `currentEquity` above but not in `cash` until the position closes. + decimal cash = Math.Max(0m, currentEquity - invested); + + // BuyingPower = cash * 2.0 is a deliberate simplification (flat 2x leverage assumption for this + // internal synthetic paper broker), not a real margin/buying-power calculation from a broker API. + return new AccountSummaryDto( + Equity: Math.Round(currentEquity, 2), + Cash: Math.Round(cash, 2), + BuyingPower: Math.Round(cash * 2.0m, 2), + Currency: "EUR", + Status: "Active" + ); + } +} diff --git a/FinlyticBot/Services/Monitoring/BotTradeLifecycleBackgroundService.cs b/FinlyticBot/Services/Monitoring/BotTradeLifecycleBackgroundService.cs new file mode 100644 index 0000000..78b81c9 --- /dev/null +++ b/FinlyticBot/Services/Monitoring/BotTradeLifecycleBackgroundService.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Bot; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticBot.Database; +using FinlyticBot.Database.Entities; +using FinlyticBot.Services.Alpaca; +using FinlyticBot.Services.Execution; +using FinlyticBot.Services.Ledger; +using FinlyticBot.Services.Mqtt; +using FinlyticBot.Settings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FinlyticBot.Services.Monitoring; + +public record BotGetCandlesRequest(string Isin, string Timeframe = "1m"); + +public class BotTradeLifecycleBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IAlpacaTradingService _alpacaService; + private readonly ISyntheticPaperBroker _syntheticBroker; + private readonly IBotRpcClient _rpcClient; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public BotTradeLifecycleBackgroundService( + IServiceScopeFactory scopeFactory, + IAlpacaTradingService alpacaService, + ISyntheticPaperBroker syntheticBroker, + IBotRpcClient rpcClient, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _alpacaService = alpacaService; + _syntheticBroker = syntheticBroker; + _rpcClient = rpcClient; + _settingsService = settingsService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, + "[BotLifecycle] Starting Bot Trade Lifecycle & Trailing Monitoring Service..."); + + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + DateTime lastSnapshotUtc = DateTime.UtcNow; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var intervalSec = await _settingsService.GetSettingAsync(BotSettingKeys.MonitoringIntervalSeconds, stoppingToken); + + using (var scope = _scopeFactory.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var openPositions = await db.Positions + .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit) + .ToListAsync(stoppingToken); + + if (openPositions.Count > 0) + { + foreach (var pos in openPositions) + { + if (stoppingToken.IsCancellationRequested) break; + + try + { + // 1. Fetch live candle for price + var candles = await _rpcClient.SendRpcRequestAsync, BotGetCandlesRequest>( + "ta_GetCandles", + new BotGetCandlesRequest(pos.Isin, "1m"), + TimeSpan.FromSeconds(3) + ); + + if (candles == null || candles.Count == 0) continue; + + var lastCandle = candles.Last(); + decimal currentPrice = lastCandle.Close; + pos.CurrentPrice = currentPrice; + pos.LastSyncAtUtc = DateTime.UtcNow; + + // 2. Check Stop-Loss Violation + bool isStopped = pos.Direction == SignalDirection.Buy + ? currentPrice <= pos.CurrentStopLoss + : currentPrice >= pos.CurrentStopLoss; + + if (isStopped) + { + pos.Status = BotPositionStatus.StoppedOut; + pos.ClosedAtUtc = DateTime.UtcNow; + decimal pnl = pos.Direction == SignalDirection.Buy + ? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur + : ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur; + pos.RealizedPnlEur = Math.Round(pnl, 2); + + await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, + "[BotLifecycle] Position {Id} for {Isin} STOPPED OUT at {Price:F2} € (PnL: {PnL:F2} €)", + pos.Id, pos.Isin, currentPrice, pos.RealizedPnlEur); + + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); + continue; + } + + // 3. Check Take-Profit 1 -> Move SL to Break-Even (Free-Roll) + bool isTp1 = pos.Direction == SignalDirection.Buy + ? currentPrice >= pos.TakeProfit1 + : currentPrice <= pos.TakeProfit1; + + if (isTp1 && pos.Status == BotPositionStatus.Active) + { + decimal oldSl = pos.CurrentStopLoss; + pos.CurrentStopLoss = pos.AverageBuyIn; + pos.Status = BotPositionStatus.BreakEvenTriggered; + + if (pos.Venue == BotExecutionVenue.AlpacaPaperTrading && !string.IsNullOrWhiteSpace(pos.AlpacaOrderId)) + { + try + { + await _alpacaService.UpdateStopLossAsync(pos.AlpacaOrderId, pos.CurrentStopLoss, stoppingToken); + } + catch (Exception ex) + { + await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex, + "[BotLifecycle] Failed to update Alpaca bracket stop-loss for order {Id}", pos.AlpacaOrderId); + } + } + + await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, + "[BotLifecycle] Position {Id} for {Isin} reached TP1 ({TP1:F2} €). Moved SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)", + pos.Id, pos.Isin, pos.TakeProfit1, oldSl, pos.AverageBuyIn); + + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); + } + + // 4. Check Take-Profit 2 + bool isTp2 = pos.Direction == SignalDirection.Buy + ? currentPrice >= pos.TakeProfit2 + : currentPrice <= pos.TakeProfit2; + + if (isTp2) + { + pos.Status = BotPositionStatus.Closed; + pos.ClosedAtUtc = DateTime.UtcNow; + decimal pnl = pos.Direction == SignalDirection.Buy + ? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur + : ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur; + pos.RealizedPnlEur = Math.Round(pnl, 2); + + await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, + "[BotLifecycle] Position {Id} for {Isin} reached TP2 ({TP2:F2} €). Closed with profit {PnL:F2} €", + pos.Id, pos.Isin, pos.TakeProfit2, pos.RealizedPnlEur); + + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); + continue; + } + + // 5. Trailing Stop Rule check + if (pos.Status == BotPositionStatus.BreakEvenTriggered && pos.ExitPlan?.TrailingStopRule != null) + { + if (pos.Direction == SignalDirection.Buy) + { + decimal trail = currentPrice * 0.97m; + if (trail > pos.CurrentStopLoss) + { + pos.CurrentStopLoss = Math.Round(trail, 2); + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); + } + } + } + + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); + } + catch (Exception ex) + { + await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, ex, + "[BotLifecycle] Error monitoring bot position {Id}", pos.Id); + } + } + } + + // Periodic Daily Snapshot + if (DateTime.UtcNow - lastSnapshotUtc >= TimeSpan.FromHours(1)) + { + var allPositions = await db.Positions.AsNoTracking().ToListAsync(stoppingToken); + decimal realized = allPositions.Sum(p => p.RealizedPnlEur); + decimal unrealized = allPositions + .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered) + .Sum(p => (p.Direction == SignalDirection.Buy ? (p.CurrentPrice - p.AverageBuyIn) : (p.AverageBuyIn - p.CurrentPrice)) * p.Quantity); + + int closedCount = allPositions.Count(p => p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut); + int winCount = allPositions.Count(p => (p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut) && p.RealizedPnlEur > 0); + decimal winRate = closedCount > 0 ? ((decimal)winCount / closedCount) * 100m : 0m; + + // Same configured base capital as SyntheticPaperBroker.GetSummaryAsync (Rules.md §4: + // no hardcoded financial constants) - this used to be a literal 50000m that could + // silently drift from the actual configured Bot.SyntheticBaseCapitalEur setting. + decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, stoppingToken); + + db.PortfolioSnapshots.Add(new BotPortfolioSnapshotEntity + { + Id = Guid.NewGuid(), + SnapshotDateUtc = DateTime.UtcNow, + TotalEquityEur = baseCapital + realized + unrealized, + CashEur = baseCapital + realized, + OpenPositionsCount = openPositions.Count, + DailyRealizedPnlEur = realized, + TotalUnrealizedPnlEur = unrealized, + WinRatePercent = Math.Round(winRate, 2), + CreatedAtUtc = DateTime.UtcNow + }); + + await db.SaveChangesAsync(stoppingToken); + lastSnapshotUtc = DateTime.UtcNow; + } + } + + await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + await _logger.LogErrorAsync(BotSettingKeys.LifecycleChannel, ex, + "[BotLifecycle] Unexpected error in bot lifecycle loop. Retrying in 15s."); + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + } + } + + await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, + "[BotLifecycle] Bot Trade Lifecycle Monitoring Service stopped."); + } +} diff --git a/FinlyticBot/Services/Mqtt/IBotRpcClient.cs b/FinlyticBot/Services/Mqtt/IBotRpcClient.cs new file mode 100644 index 0000000..b5d9210 --- /dev/null +++ b/FinlyticBot/Services/Mqtt/IBotRpcClient.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; + +namespace FinlyticBot.Services.Mqtt; + +public interface IBotRpcClient +{ + Task SendRpcRequestAsync( + string channel, + TRequest requestData, + TimeSpan? timeout = null) + where TResponse : class + where TRequest : class; + + Task PublishAsync(string topic, T data, bool retain = false); +} diff --git a/FinlyticBot/Settings/BotSettingKeys.cs b/FinlyticBot/Settings/BotSettingKeys.cs new file mode 100644 index 0000000..208d464 --- /dev/null +++ b/FinlyticBot/Settings/BotSettingKeys.cs @@ -0,0 +1,30 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticBot.Settings; + +public static class BotSettingKeys +{ + // --- Logging Channels --- + public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); + public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); + public static readonly SettingKey BotChannel = new("Logging.Channel.Bot", true); + public static readonly SettingKey AlpacaChannel = new("Logging.Channel.Alpaca", true); + public static readonly SettingKey LedgerChannel = new("Logging.Channel.Ledger", true); + public static readonly SettingKey LifecycleChannel = new("Logging.Channel.Lifecycle", true); + + // --- Alpaca Credentials (Admin Panel konfigurierbar) --- + public static readonly SettingKey AlpacaKeyId = new("Alpaca.KeyId", ""); + public static readonly SettingKey AlpacaSecretKey = new("Alpaca.SecretKey", ""); + public static readonly SettingKey AlpacaIsPaper = new("Alpaca.IsPaper", true); + + // --- Risk & Dynamic Sizing Rules (1-2% Regel) --- + public static readonly SettingKey EnableAutoExecution = new("Bot.EnableAutoExecution", true); + public static readonly SettingKey RiskPerTradePercent = new("Bot.RiskPerTradePercent", 1.0m); // Standard 1.0% (1-2% Regel) + public static readonly SettingKey MaxPositionAllocationPercent = new("Bot.MaxPositionAllocationPercent", 20.0m); // Max 20% des Gesamtkapitals pro Position + public static readonly SettingKey MaxConcurrentPositions = new("Bot.MaxConcurrentPositions", 5); + public static readonly SettingKey DailyLossLimitPercent = new("Bot.DailyLossLimitPercent", 3.0m); + public static readonly SettingKey MonitoringIntervalSeconds = new("Bot.MonitoringIntervalSeconds", 15); + + // --- Synthetic Paper Broker: Startkapital des internen Ledgers (EUR) --- + public static readonly SettingKey SyntheticBaseCapitalEur = new("Bot.SyntheticBaseCapitalEur", 50000.0m); +} diff --git a/FinlyticBot/Util/BotMqttClient.cs b/FinlyticBot/Util/BotMqttClient.cs index 8c97fb1..e55a088 100644 --- a/FinlyticBot/Util/BotMqttClient.cs +++ b/FinlyticBot/Util/BotMqttClient.cs @@ -1,15 +1,26 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using FinlyticBot.Services; using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Bot; using FinlyticCore.Dtos.Settings; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; using FinlyticCore.Models; -using FinlyticCore.Models.Trades; using FinlyticCore.Services; using FinlyticCore.Util; +using FinlyticBot.Database; +using FinlyticBot.Database.Entities; +using FinlyticBot.Services.Alpaca; +using FinlyticBot.Services.Consumers; +using FinlyticBot.Services.Execution; +using FinlyticBot.Services.Ledger; +using FinlyticBot.Services.Mqtt; +using FinlyticBot.Settings; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -17,176 +28,360 @@ using Microsoft.Extensions.Logging; namespace FinlyticBot.Util; -public class BotMqttClient : ManagedMqttClient, IHostedService +public class BotMqttClient : ManagedMqttClient, IHostedService, IBotRpcClient { private readonly IConfiguration _configuration; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly IFinlyticLogger _finlyticLogger; public BotMqttClient( + ILogger logger, IConfiguration configuration, IServiceScopeFactory scopeFactory, - ILogger logger) : base(logger) + IFinlyticLogger finlyticLogger) : base(logger) { + _logger = logger; _configuration = configuration; _scopeFactory = scopeFactory; - _logger = logger; + _finlyticLogger = finlyticLogger; } 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_bot")}_{Guid.NewGuid():N}" - }; + var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticBot"); - _logger.LogInformation("Starting FinlyticBot MQTT Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); + _logger.LogInformation("Starting FinlyticBot MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); + await _finlyticLogger.LogInfoAsync(BotSettingKeys.MqttChannel, "[BotMqttClient] Starting FinlyticBot MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); await ConnectAsync(config); } public async Task StopAsync(CancellationToken cancellationToken) { - _logger.LogInformation("Stopping FinlyticBot MQTT Client."); + _logger.LogInformation("Stopping FinlyticBot MQTT client."); + // Broadcast via IFinlyticLogger too (see OnConnectedAsync's doc comment) so the live console shows a + // clean "stopped" line instead of just silently going quiet. + await _finlyticLogger.LogInfoAsync(BotSettingKeys.MqttChannel, "[BotMqttClient] Stopping FinlyticBot MQTT client."); await DisconnectAsync(); } protected override async Task OnConnectedAsync() { - _logger.LogInformation("FinlyticBot MQTT Client connected. Subscribing to topics..."); + _logger.LogInformation("FinlyticBot MQTT client connected. Registering RPC endpoints..."); - await SubscribeAsync("services/events/trades/proposal"); - await SubscribeAsync("finlytic/trades/proposed/#"); - await SubscribeAsync("services/events/analyzer/trade_proposed"); - await SubscribeAsync("services/request/bot_Settings_GetAll/#"); - await SubscribeAsync("services/request/bot_Settings_Update/#"); - await SubscribeAsync("services/request/health_Ping/#"); + await SubscribeAsync(MqttTopics.ResponseWildcard); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.BotGetStatus), HandleGetStatusRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.BotGetPositions), HandleGetPositionsRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.BotGetSummary), HandleGetSummaryRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.BotExecuteProposal), HandleExecuteProposalRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.BotPanicClose), HandlePanicCloseRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.BotSettingsGetAll), HandleSettingsGetAllRpcAsync); + await SubscribeRpcAsync, List>(MqttTopics.RequestFilter(MqttTopics.Channels.BotSettingsUpdate), HandleSettingsUpdateRpcAsync); + await SubscribeAsync(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync); - FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + // Subscribe to Engine Proposals + await SubscribeAsync(MqttTopics.EngineProposalsCreated); + + FinlyticLogBroadcaster.OnLogPublished = async (logDto) => { if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticBot", StringComparison.OrdinalIgnoreCase)) { - await PublishAsync("finlytic/logs/FinlyticBot", logDto); + await PublishAsync(MqttTopics.Logs("FinlyticBot"), logDto); } }; - _logger.LogInformation("Successfully subscribed to FinlyticBot event and RPC channels."); + await _finlyticLogger.LogInfoAsync(BotSettingKeys.MqttChannel, "[BotMqttClient] FinlyticBot MQTT client connected. Registering RPC endpoints..."); } protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) { + if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return; + try { - if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + if (topic.Equals(MqttTopics.EngineProposalsCreated, StringComparison.OrdinalIgnoreCase)) { - var segments = topic.Split('/'); - bool isForMe = segments.Length >= 5 - ? segments[3].Equals("FinlyticBot", StringComparison.OrdinalIgnoreCase) - : topic.Contains("FinlyticBot", StringComparison.OrdinalIgnoreCase); - - if (isForMe) + var proposal = JsonSerializer.Deserialize(payloadStr, DefaultJsonOptions); + if (proposal != null && EngineProposalConsumerBackgroundService.OnProposalReceived != null) { - string correlationId = segments[^1]; - string respTopic = $"services/response/health_Ping/{correlationId}"; - await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticBot", "Online", DateTime.UtcNow, "Connected")); - - using var scope = _scopeFactory.CreateScope(); - var finlyticLogger = scope.ServiceProvider.GetRequiredService>(); - await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, - "[FinlyticBot] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId); + EngineProposalConsumerBackgroundService.OnProposalReceived(proposal); } - return; - } - - if (topic.StartsWith("services/request/bot_Settings_GetAll/", StringComparison.OrdinalIgnoreCase)) - { - var correlationId = topic.Split('/')[^1]; - await HandleSettingsGetAllAsync(correlationId); - return; - } - - if (topic.StartsWith("services/request/bot_Settings_Update/", StringComparison.OrdinalIgnoreCase)) - { - var correlationId = topic.Split('/')[^1]; - await HandleSettingsUpdateAsync(correlationId, payloadStr); - return; - } - - if (topic.Equals("services/events/trades/proposal", StringComparison.OrdinalIgnoreCase) || - topic.StartsWith("finlytic/trades/proposed/", StringComparison.OrdinalIgnoreCase) || - topic.Equals("services/events/analyzer/trade_proposed", StringComparison.OrdinalIgnoreCase)) - { - await HandleTradeProposalEventAsync(payloadStr); - return; } } catch (Exception ex) { - _logger.LogError(ex, "Error processing incoming MQTT message on topic {Topic}", topic); + _logger.LogError(ex, "[BotMqttClient] Error handling incoming proposal on topic {Topic}", topic); } } - private async Task HandleTradeProposalEventAsync(string payloadStr) - { - if (string.IsNullOrWhiteSpace(payloadStr)) return; - - TradeProposalDto? proposal = null; - try - { - proposal = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TradeProposalDto); - } - catch - { - proposal = JsonSerializer.Deserialize(payloadStr); - } - - if (proposal == null) return; - - using var scope = _scopeFactory.CreateScope(); - var executionService = scope.ServiceProvider.GetRequiredService(); - await executionService.ProcessTradeProposalAsync(proposal); - } - - private async Task HandleSettingsGetAllAsync(string correlationId) - { - using var scope = _scopeFactory.CreateScope(); - var settingsService = scope.ServiceProvider.GetRequiredService(); - var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); - - var responseTopic = $"services/response/bot_Settings_GetAll/{correlationId}"; - await PublishAsync(responseTopic, settings); - } - - private async Task HandleSettingsUpdateAsync(string correlationId, string payload) + private async Task HandleGetStatusRpcAsync(object? _, string correlationId) { using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alpacaService = scope.ServiceProvider.GetRequiredService(); var settingsService = scope.ServiceProvider.GetRequiredService(); - Dictionary? updates = null; + int active = await db.Positions.CountAsync(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered); + bool autoExec = await settingsService.GetSettingAsync(BotSettingKeys.EnableAutoExecution); + int maxPositions = await settingsService.GetSettingAsync(BotSettingKeys.MaxConcurrentPositions); + decimal riskPerTradePercent = await settingsService.GetSettingAsync(BotSettingKeys.RiskPerTradePercent); + + // MinCompositeScore is owned by FinlyticEngine (EngineSettingKeys.MinCompositeScore == + // "Engine.MinCompositeScore", read as `decimal` there — see TradeLifecycleService). FinlyticBot has no + // project reference to FinlyticEngine (and is out of scope for adding one here), but both services share + // the same dynamic settings store, so the raw string key is read directly instead of duplicating/inventing + // a Bot-local setting. Default mirrors EngineSettingKeys' own documented default. + decimal minCompositeScoreRaw = await settingsService.GetSettingAsync("Engine.MinCompositeScore", 75.0m); + + // The bot worker process itself is always running once started; "IsRunning" from the client's + // perspective means "is the bot actively acting on proposals", which is exactly what + // EngineProposalConsumerBackgroundService gates on before calling IBotOrderExecutor. So IsRunning + // is deliberately the same flag as AutoExecutionEnabled rather than a separate process-alive flag. + bool isRunning = autoExec; + + // Synthetic broker is always available (in-process ledger); Alpaca is only listed once real + // credentials are configured (IAlpacaTradingService.IsConfigured), mirroring the venue selection + // logic in BotOrderExecutor. + string venuesActive = alpacaService.IsConfigured + ? $"{BotExecutionVenue.SyntheticPaperBroker}, {BotExecutionVenue.AlpacaPaperTrading}" + : BotExecutionVenue.SyntheticPaperBroker.ToString(); + + return new BotStatusDto( + IsRunning: isRunning, + AutoExecutionEnabled: autoExec, + ActivePositionsCount: active, + MaxPositions: maxPositions, + RiskPerTradePercent: riskPerTradePercent, + MinCompositeScore: (int)Math.Round(minCompositeScoreRaw, MidpointRounding.AwayFromZero), + VenuesActive: venuesActive + ); + } + + private async Task> HandleGetPositionsRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var positions = await db.Positions + .AsNoTracking() + .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit) + .OrderByDescending(p => p.OpenedAtUtc) + .ToListAsync(); + + return positions.Select(BotOrderExecutor.MapEntityToDto).ToList(); + } + + private async Task HandleGetSummaryRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var syntheticBroker = scope.ServiceProvider.GetRequiredService(); + return await syntheticBroker.GetSummaryAsync(); + } + + private async Task HandleExecuteProposalRpcAsync(ExecuteProposalRequest? req, string correlationId) + { + if (req == null) return null; + + using var scope = _scopeFactory.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + // Look up the existing proposal by its actual GUID via the engine_GetProposals RPC channel — the + // same channel EngineController/UserTradesController already use for proposal listings. + // (req.ProposalId is a proposal GUID, not an ISIN; a previous version of this method sent it to + // engine_EvaluateIsin as if it were one, which started a full re-evaluation of a nonsensical "ISIN" + // and could never resolve a proposal — see Bug A of the security review.) + // A generous limit is used because the proposal the caller wants to execute may not be among the + // most recently created handful if several proposals were generated in quick succession. + List? proposals; try { - updates = JsonSerializer.Deserialize>(payload); + proposals = await SendRpcRequestAsync, GetTradeProposalsRequest>( + MqttTopics.Channels.EngineGetProposals, + new GetTradeProposalsRequest(OnlyActive: true, Limit: 200), + TimeSpan.FromSeconds(5) + ); } - catch + catch (Exception ex) { - var list = JsonSerializer.Deserialize>(payload); - if (list != null) + await logger.LogWarningAsync(BotSettingKeys.BotChannel, ex, + "[BotMqttClient] RPC failure while fetching proposals from FinlyticEngine to resolve proposal {ProposalId} for execution [CorrelationId: {CorrelationId}]", + req.ProposalId, correlationId); + return null; + } + + if (proposals == null) + { + await logger.LogWarningAsync(BotSettingKeys.BotChannel, + "[BotMqttClient] FinlyticEngine did not respond to engine_GetProposals in time while resolving proposal {ProposalId} for execution [CorrelationId: {CorrelationId}]", + req.ProposalId, correlationId); + return null; + } + + var proposal = proposals.FirstOrDefault(p => p.ProposalId == req.ProposalId); + if (proposal == null) + { + await logger.LogWarningAsync(BotSettingKeys.BotChannel, + "[BotMqttClient] Proposal {ProposalId} was not found among FinlyticEngine's active proposals (expired or invalid) [CorrelationId: {CorrelationId}]", + req.ProposalId, correlationId); + return null; + } + + await logger.LogInfoAsync(BotSettingKeys.BotChannel, + "[BotMqttClient] Executing manual paper trade for proposal {Id} [CorrelationId: {CorrelationId}]", req.ProposalId, correlationId); + return await executor.ExecuteProposalAsync(proposal, req.PreferredVenue, req.CustomQuantity); + } + + /// + /// Emergency-closes every currently open paper-trading position (Status in Active, BreakEvenTriggered, + /// Tp1Hit, Tp2Hit — the terminal statuses Closed/StoppedOut/KnockedOut/Canceled are, by definition, + /// already not open and Pending is not currently assigned by any code path). + /// + /// Synthetic ledger positions are closed unconditionally via — there + /// is no external broker to confirm with, so the internal ledger IS the authority. + /// + /// + /// Alpaca positions are the safety-critical case: this handler NEVER marks an Alpaca position as closed + /// unless returns successfully (i.e. Alpaca's REST + /// API confirmed it accepted the liquidation order). If Alpaca is not configured, or the broker call + /// throws, the position is left completely untouched in the database and is counted in + /// rather than silently reported as closed + /// (Rules.md §4 — a false-positive "closed" on a real broker position would be fatal). + /// + /// + private async Task HandlePanicCloseRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var syntheticBroker = scope.ServiceProvider.GetRequiredService(); + var alpacaService = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + var openStatuses = new[] + { + BotPositionStatus.Active, + BotPositionStatus.BreakEvenTriggered, + BotPositionStatus.Tp1Hit, + BotPositionStatus.Tp2Hit + }; + + var positions = await db.Positions + .Where(p => openStatuses.Contains(p.Status)) + .OrderBy(p => p.OpenedAtUtc) + .ToListAsync(); + + await logger.LogWarningAsync(BotSettingKeys.BotChannel, + "[BotMqttClient] PANIC CLOSE triggered: attempting to close {Count} open position(s) [CorrelationId: {CorrelationId}]", + positions.Count, correlationId); + + var closedOrders = new List(); + int skippedCount = 0; + + foreach (var pos in positions) + { + if (pos.Venue == BotExecutionVenue.SyntheticPaperBroker) { - updates = new Dictionary(); - foreach (var item in list) updates[item.Key] = item.Value; + // No external broker to confirm with — the internal ledger is the authority for its own + // positions, so this closes unconditionally at the last synced price (same direction-aware + // realized P&L formula ISyntheticPaperBroker already uses elsewhere for consistency). + var closed = await syntheticBroker.ClosePositionAsync(pos.Id, pos.CurrentPrice, BotPositionStatus.Closed); + closedOrders.Add(BotOrderExecutor.MapEntityToDto(closed)); + continue; + } + + // Alpaca venue: only ever mark closed after the broker confirms the liquidation. + if (!alpacaService.IsConfigured) + { + await logger.LogWarningAsync(BotSettingKeys.BotChannel, + "[BotMqttClient] PANIC CLOSE SKIPPED Alpaca position {PositionId} ({Symbol}): Alpaca is not configured. Position left OPEN in the ledger — manual intervention required.", + pos.Id, pos.Symbol); + skippedCount++; + continue; + } + + try + { + var closeResult = await alpacaService.ClosePositionAsync(pos.Symbol); + + // Alpaca confirmed acceptance of the liquidation order — only now is it safe to persist + // the position as closed. AverageFillPrice can still be null immediately after submission + // (e.g. outside market hours); fall back to the last synced price rather than fabricating one. + decimal exitPrice = closeResult.AverageFillPrice ?? pos.CurrentPrice; + pos.Status = BotPositionStatus.Closed; + pos.ClosedAtUtc = DateTime.UtcNow; + pos.CurrentPrice = exitPrice; + pos.LastSyncAtUtc = DateTime.UtcNow; + pos.RealizedPnlEur = CalculateRealizedPnl(pos, exitPrice); + + await db.SaveChangesAsync(); + + await logger.LogInfoAsync(BotSettingKeys.BotChannel, + "[BotMqttClient] PANIC CLOSE confirmed by Alpaca for position {PositionId} ({Symbol}): Order {OrderId} (Status: {Status}), exit {Exit:F2} €.", + pos.Id, pos.Symbol, closeResult.OrderId, closeResult.OrderStatus, exitPrice); + + closedOrders.Add(BotOrderExecutor.MapEntityToDto(pos)); + } + catch (Exception ex) + { + // The broker call itself failed (not configured mid-flight, network/HTTP error, or Alpaca + // rejected the request) — the position is left completely untouched, exactly as it was + // before this handler ran. It must NOT be counted as closed. + await logger.LogWarningAsync(BotSettingKeys.BotChannel, ex, + "[BotMqttClient] PANIC CLOSE FAILED for Alpaca position {PositionId} ({Symbol}): broker call did not confirm liquidation. Position left OPEN in the ledger — manual intervention required.", + pos.Id, pos.Symbol); + skippedCount++; } } + return new PanicCloseResultDto(closedOrders.Count, skippedCount, closedOrders); + } + + /// + /// Direction-aware realized P&L for a position closed at , mirroring the + /// formula already uses for its own (non-knock-out) closes, so both + /// venues report P&L consistently. Only used for the Alpaca panic-close path here — the synthetic path + /// delegates to directly, which computes its own. + /// + private static decimal CalculateRealizedPnl(BotPositionEntity pos, decimal exitPrice) + { + decimal pnl = pos.Direction == SignalDirection.Buy + ? ((exitPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur + : ((pos.AverageBuyIn - exitPrice) * pos.Quantity) - pos.TotalFeesEur; + + return Math.Round(pnl, 2); + } + + private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BotSettingKeys) }); + } + + private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + if (updates != null && updates.Count > 0) { await settingsService.UpdateSettingsAsync(updates); } - var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) }); - var responseTopic = $"services/response/bot_Settings_Update/{correlationId}"; - await PublishAsync(responseTopic, currentSettings); + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BotSettingKeys) }); + } + + private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId) + { + if (topic.Contains("FinlyticBot", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticBot", "Online", DateTime.UtcNow, "Connected")); + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + await logger.LogInfoAsync(BotSettingKeys.HealthPingChannel, + "[FinlyticBot] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId); + } } } diff --git a/FinlyticBot/Util/SettingKeys.cs b/FinlyticBot/Util/SettingKeys.cs deleted file mode 100644 index 0776aa5..0000000 --- a/FinlyticBot/Util/SettingKeys.cs +++ /dev/null @@ -1,36 +0,0 @@ -using FinlyticCore.Models.Settings; - -namespace FinlyticBot.Util; - -public static class SettingKeys -{ - // --- Logging-Kanäle --- - public static readonly SettingKey BotChannel = new("Logging.Channel.Bot", true); - public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); - public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); - - // --- Master Bot Control --- - public static readonly SettingKey IsEnabled = new("Bot.IsEnabled", false); - - // --- Filter & Zulassungskriterien --- - public static readonly SettingKey MinCrv = new("Bot.MinCrv", 1.50); - public static readonly SettingKey MinWinRate = new("Bot.MinWinRate", 65.0); - public static readonly SettingKey MaxVixThreshold = new("Bot.MaxVixThreshold", 25.0); - public static readonly SettingKey MaxEntryDeviationPercent = new("Bot.MaxEntryDeviationPercent", 0.75); - - // --- Risiko-Management & Positionsgrößen --- - public static readonly SettingKey RiskPerTradePercent = new("Bot.RiskPerTradePercent", 1.0); - public static readonly SettingKey MaxSinglePositionCap = new("Bot.MaxSinglePositionCap", 5000.0); - public static readonly SettingKey MaxOpenTrades = new("Bot.MaxOpenTrades", 5); - public static readonly SettingKey DailyLossLimitPercent = new("Bot.DailyLossLimitPercent", 3.0); - public static readonly SettingKey MaxConsecutiveLosses = new("Bot.MaxConsecutiveLosses", 3); - - // --- Order Execution Strategie --- - public static readonly SettingKey TakeProfitMode = new("Bot.TakeProfitMode", "Split50_50"); // "TP1_Only", "TP2_Only", "Split50_50" - public static readonly SettingKey ExecutionOrderType = new("Bot.ExecutionOrderType", "Limit"); // "Limit", "Market" - - // --- Alpaca API Konfiguration (Optional live im UI überschreibbar) --- - public static readonly SettingKey AlpacaKeyId = new("Alpaca.KeyId", ""); - public static readonly SettingKey AlpacaSecretKey = new("Alpaca.SecretKey", ""); - public static readonly SettingKey AlpacaIsPaper = new("Alpaca.IsPaper", true); -} diff --git a/FinlyticBot/appsettings.json b/FinlyticBot/appsettings.json index 7f8924b..5971b56 100644 --- a/FinlyticBot/appsettings.json +++ b/FinlyticBot/appsettings.json @@ -2,11 +2,12 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" } }, "ConnectionStrings": { - "DefaultConnection": "Host=OmniDB;Database=finlytic_bot;Username=admin;Password=YourPasswordHere" + "DefaultConnection": "Host=localhost;Database=finlytic_bot;Username=postgres;Password=postgres" }, "MQTT": { "Host": "localhost", @@ -14,8 +15,8 @@ "ClientId": "finlytic_bot" }, "Alpaca": { - "KeyId": "", - "SecretKey": "", + "KeyId": "PK_PAPER_PLACEHOLDER_KEY", + "SecretKey": "SK_PAPER_PLACEHOLDER_SECRET", "IsPaper": true } }