From 5497cc5de73b0d0440f5ff39da222dbf3b5de804 Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Mon, 17 Aug 2026 16:32:47 +0200 Subject: [PATCH] feat(bot): add FinlyticBot autonomous paper trading microservice with Alpaca Markets API integration --- Finlytic.sln | 28 +-- .../Controllers/AdminSettingsController.cs | 3 +- FinlyticBot/Database/BotDbContext.cs | 45 ++++ FinlyticBot/Dockerfile | 16 ++ FinlyticBot/Entities/BotAuditLogEntity.cs | 37 +++ .../Entities/ExecutedPaperTradeEntity.cs | 78 +++++++ FinlyticBot/FinlyticBot.csproj | 24 ++ ...20260817142432_InitBotDatabase.Designer.cs | 217 ++++++++++++++++++ .../20260817142432_InitBotDatabase.cs | 153 ++++++++++++ .../Migrations/BotDbContextModelSnapshot.cs | 214 +++++++++++++++++ FinlyticBot/Program.cs | 50 ++++ FinlyticBot/Services/AlpacaBrokerService.cs | 216 +++++++++++++++++ .../Services/AlpacaWebSocketMonitorWorker.cs | 208 +++++++++++++++++ .../Services/BotOrderExecutionWorker.cs | 191 +++++++++++++++ FinlyticBot/Services/BotRiskSizingService.cs | 142 ++++++++++++ FinlyticBot/Services/IAlpacaBrokerService.cs | 26 +++ FinlyticBot/Services/IBotRiskSizingService.cs | 25 ++ FinlyticBot/Util/BotMqttClient.cs | 192 ++++++++++++++++ FinlyticBot/Util/SettingKeys.cs | 36 +++ FinlyticBot/appsettings.json | 21 ++ compose.yaml | 16 ++ 21 files changed, 1924 insertions(+), 14 deletions(-) create mode 100644 FinlyticBot/Database/BotDbContext.cs create mode 100644 FinlyticBot/Dockerfile create mode 100644 FinlyticBot/Entities/BotAuditLogEntity.cs create mode 100644 FinlyticBot/Entities/ExecutedPaperTradeEntity.cs create mode 100644 FinlyticBot/FinlyticBot.csproj create mode 100644 FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs create mode 100644 FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs create mode 100644 FinlyticBot/Migrations/BotDbContextModelSnapshot.cs create mode 100644 FinlyticBot/Program.cs create mode 100644 FinlyticBot/Services/AlpacaBrokerService.cs create mode 100644 FinlyticBot/Services/AlpacaWebSocketMonitorWorker.cs create mode 100644 FinlyticBot/Services/BotOrderExecutionWorker.cs create mode 100644 FinlyticBot/Services/BotRiskSizingService.cs create mode 100644 FinlyticBot/Services/IAlpacaBrokerService.cs create mode 100644 FinlyticBot/Services/IBotRiskSizingService.cs create mode 100644 FinlyticBot/Util/BotMqttClient.cs create mode 100644 FinlyticBot/Util/SettingKeys.cs create mode 100644 FinlyticBot/appsettings.json diff --git a/Finlytic.sln b/Finlytic.sln index 1f50a64..04af16c 100644 --- a/Finlytic.sln +++ b/Finlytic.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}" EndProject @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTrades", "FinlyticT EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot", "FinlyticBot\FinlyticBot.csproj", "{B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,18 +71,6 @@ Global {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x64.Build.0 = Release|Any CPU {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.ActiveCfg = Release|Any CPU {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.Build.0 = Release|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x64.ActiveCfg = Debug|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x64.Build.0 = Debug|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x86.ActiveCfg = Debug|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x86.Build.0 = Debug|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|Any CPU.Build.0 = Release|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x64.ActiveCfg = Release|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x64.Build.0 = Release|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x86.ActiveCfg = Release|Any CPU - {999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x86.Build.0 = Release|Any CPU {D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.Build.0 = Debug|Any CPU {D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -119,6 +109,18 @@ Global {C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.Build.0 = Release|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Debug|x64.Build.0 = Debug|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Debug|x86.Build.0 = Debug|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Release|Any CPU.Build.0 = Release|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Release|x64.ActiveCfg = Release|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Release|x64.Build.0 = Release|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Release|x86.ActiveCfg = Release|Any CPU + {B39B0AD9-FB8A-4F5F-8652-73C3BD8E75F3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FinlyticBackend/Controllers/AdminSettingsController.cs b/FinlyticBackend/Controllers/AdminSettingsController.cs index 499994d..f379e43 100644 --- a/FinlyticBackend/Controllers/AdminSettingsController.cs +++ b/FinlyticBackend/Controllers/AdminSettingsController.cs @@ -67,7 +67,8 @@ public class AdminSettingsController : ControllerBase ["FinlyticSentiment"] = "sentiment", ["FinlyticAnalyzer"] = "analyzer", ["FinlyticTrades"] = "trades", - ["FinlyticAssets"] = "assets" + ["FinlyticAssets"] = "assets", + ["FinlyticBot"] = "bot" }; private static readonly ConcurrentDictionary> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase); diff --git a/FinlyticBot/Database/BotDbContext.cs b/FinlyticBot/Database/BotDbContext.cs new file mode 100644 index 0000000..bb3356f --- /dev/null +++ b/FinlyticBot/Database/BotDbContext.cs @@ -0,0 +1,45 @@ +using System; +using FinlyticBot.Entities; +using FinlyticCore.Database; +using FinlyticCore.Entities.Settings; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticBot.Database; + +public class BotDbContext : DbContext, ISettingsDbContext +{ + public BotDbContext(DbContextOptions options) : base(options) { } + + public DbSet DynamicSettings => Set(); + public DbSet ExecutedPaperTrades => Set(); + public DbSet BotAuditLogs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Key).IsUnique(); + }); + + 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.TradeId); + entity.HasIndex(e => e.Symbol); + entity.HasIndex(e => e.Action); + entity.HasIndex(e => e.Timestamp); + }); + } +} diff --git a/FinlyticBot/Dockerfile b/FinlyticBot/Dockerfile new file mode 100644 index 0000000..0638d23 --- /dev/null +++ b/FinlyticBot/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +COPY ["FinlyticBot/FinlyticBot.csproj", "FinlyticBot/"] +RUN dotnet restore "FinlyticBot/FinlyticBot.csproj" +COPY . . +WORKDIR "/src/FinlyticBot" +RUN dotnet build "FinlyticBot.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "FinlyticBot.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticBot.dll"] diff --git a/FinlyticBot/Entities/BotAuditLogEntity.cs b/FinlyticBot/Entities/BotAuditLogEntity.cs new file mode 100644 index 0000000..9ae8463 --- /dev/null +++ b/FinlyticBot/Entities/BotAuditLogEntity.cs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..80e5cb2 --- /dev/null +++ b/FinlyticBot/Entities/ExecutedPaperTradeEntity.cs @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..bee023a --- /dev/null +++ b/FinlyticBot/FinlyticBot.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs b/FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs new file mode 100644 index 0000000..0913acd --- /dev/null +++ b/FinlyticBot/Migrations/20260817142432_InitBotDatabase.Designer.cs @@ -0,0 +1,217 @@ +// +using System; +using FinlyticBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticBot.Migrations +{ + [DbContext(typeof(BotDbContext))] + [Migration("20260817142432_InitBotDatabase")] + partial class InitBotDatabase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticBot.Entities.BotAuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountEquity") + .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") + .HasColumnType("timestamp with time zone"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("Symbol"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TradeId"); + + b.ToTable("BotAuditLogs"); + }); + + modelBuilder.Entity("FinlyticBot.Entities.ExecutedPaperTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualFillPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("AlpacaOrderId") + .HasColumnType("uuid"); + + b.Property("CalculatedCrv") + .HasColumnType("decimal(18,4)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FilledAt") + .HasColumnType("timestamp with time zone"); + + 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") + .HasColumnType("decimal(18,4)"); + + b.Property("SlippagePercent") + .HasColumnType("decimal(18,4)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("StopLossPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TakeProfitPrice1") + .HasColumnType("decimal(18,4)"); + + b.Property("TakeProfitPrice2") + .HasColumnType("decimal(18,4)"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinRate") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("AlpacaOrderId"); + + b.HasIndex("Isin"); + + b.HasIndex("PlacedAt"); + + b.HasIndex("Status"); + + b.HasIndex("Symbol"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("ExecutedPaperTrades"); + }); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs b/FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs new file mode 100644 index 0000000..a900a68 --- /dev/null +++ b/FinlyticBot/Migrations/20260817142432_InitBotDatabase.cs @@ -0,0 +1,153 @@ +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/BotDbContextModelSnapshot.cs b/FinlyticBot/Migrations/BotDbContextModelSnapshot.cs new file mode 100644 index 0000000..526a99e --- /dev/null +++ b/FinlyticBot/Migrations/BotDbContextModelSnapshot.cs @@ -0,0 +1,214 @@ +// +using System; +using FinlyticBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticBot.Migrations +{ + [DbContext(typeof(BotDbContext))] + partial class BotDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticBot.Entities.BotAuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountEquity") + .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") + .HasColumnType("timestamp with time zone"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("Symbol"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TradeId"); + + b.ToTable("BotAuditLogs"); + }); + + modelBuilder.Entity("FinlyticBot.Entities.ExecutedPaperTradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualFillPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("AlpacaOrderId") + .HasColumnType("uuid"); + + b.Property("CalculatedCrv") + .HasColumnType("decimal(18,4)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FilledAt") + .HasColumnType("timestamp with time zone"); + + 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") + .HasColumnType("decimal(18,4)"); + + b.Property("SlippagePercent") + .HasColumnType("decimal(18,4)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("StopLossPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TakeProfitPrice1") + .HasColumnType("decimal(18,4)"); + + b.Property("TakeProfitPrice2") + .HasColumnType("decimal(18,4)"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WinRate") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("AlpacaOrderId"); + + b.HasIndex("Isin"); + + b.HasIndex("PlacedAt"); + + b.HasIndex("Status"); + + b.HasIndex("Symbol"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("ExecutedPaperTrades"); + }); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("DynamicSettings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticBot/Program.cs b/FinlyticBot/Program.cs new file mode 100644 index 0000000..81183fd --- /dev/null +++ b/FinlyticBot/Program.cs @@ -0,0 +1,50 @@ +using System; +using FinlyticBot.Database; +using FinlyticBot.Services; +using FinlyticBot.Util; +using FinlyticCore.Database; +using FinlyticCore.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +// 1. DbContext (Scoped) +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// 2. Core Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); + +// 3. Domain Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 4. Hosted Services +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Run DB Migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + Console.WriteLine("Database migrations successfully executed for FinlyticBot."); + } + catch (Exception ex) + { + Console.WriteLine($"Critical error during database migration for FinlyticBot: {ex.Message}"); + } +} + +await host.RunAsync(); diff --git a/FinlyticBot/Services/AlpacaBrokerService.cs b/FinlyticBot/Services/AlpacaBrokerService.cs new file mode 100644 index 0000000..1dfcc52 --- /dev/null +++ b/FinlyticBot/Services/AlpacaBrokerService.cs @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..307a457 --- /dev/null +++ b/FinlyticBot/Services/AlpacaWebSocketMonitorWorker.cs @@ -0,0 +1,208 @@ +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 new file mode 100644 index 0000000..0f21937 --- /dev/null +++ b/FinlyticBot/Services/BotOrderExecutionWorker.cs @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..71a847b --- /dev/null +++ b/FinlyticBot/Services/BotRiskSizingService.cs @@ -0,0 +1,142 @@ +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/IAlpacaBrokerService.cs b/FinlyticBot/Services/IAlpacaBrokerService.cs new file mode 100644 index 0000000..4b4e04a --- /dev/null +++ b/FinlyticBot/Services/IAlpacaBrokerService.cs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..ef3d5f4 --- /dev/null +++ b/FinlyticBot/Services/IBotRiskSizingService.cs @@ -0,0 +1,25 @@ +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/Util/BotMqttClient.cs b/FinlyticBot/Util/BotMqttClient.cs new file mode 100644 index 0000000..8c97fb1 --- /dev/null +++ b/FinlyticBot/Util/BotMqttClient.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticBot.Services; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Settings; +using FinlyticCore.Models; +using FinlyticCore.Models.Trades; +using FinlyticCore.Services; +using FinlyticCore.Util; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticBot.Util; + +public class BotMqttClient : ManagedMqttClient, IHostedService +{ + private readonly IConfiguration _configuration; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public BotMqttClient( + IConfiguration configuration, + IServiceScopeFactory scopeFactory, + ILogger logger) : base(logger) + { + _configuration = configuration; + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = new MqttConfiguration + { + Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost", + Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"), + Username = _configuration["MQTT:Username"] ?? _configuration["MQTT__Username"], + Password = _configuration["MQTT:Password"] ?? _configuration["MQTT__Password"], + ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_bot")}_{Guid.NewGuid():N}" + }; + + _logger.LogInformation("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."); + await DisconnectAsync(); + } + + protected override async Task OnConnectedAsync() + { + _logger.LogInformation("FinlyticBot MQTT Client connected. Subscribing to topics..."); + + 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/#"); + + FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticBot", StringComparison.OrdinalIgnoreCase)) + { + await PublishAsync("finlytic/logs/FinlyticBot", logDto); + } + }; + + _logger.LogInformation("Successfully subscribed to FinlyticBot event and RPC channels."); + } + + protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) + { + try + { + if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + { + var segments = topic.Split('/'); + bool isForMe = segments.Length >= 5 + ? segments[3].Equals("FinlyticBot", StringComparison.OrdinalIgnoreCase) + : topic.Contains("FinlyticBot", StringComparison.OrdinalIgnoreCase); + + if (isForMe) + { + 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); + } + 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); + } + } + + 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) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + Dictionary? updates = null; + try + { + updates = JsonSerializer.Deserialize>(payload); + } + catch + { + var list = JsonSerializer.Deserialize>(payload); + if (list != null) + { + updates = new Dictionary(); + foreach (var item in list) updates[item.Key] = item.Value; + } + } + + 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); + } +} diff --git a/FinlyticBot/Util/SettingKeys.cs b/FinlyticBot/Util/SettingKeys.cs new file mode 100644 index 0000000..0776aa5 --- /dev/null +++ b/FinlyticBot/Util/SettingKeys.cs @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..7f8924b --- /dev/null +++ b/FinlyticBot/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=OmniDB;Database=finlytic_bot;Username=admin;Password=YourPasswordHere" + }, + "MQTT": { + "Host": "localhost", + "Port": 1883, + "ClientId": "finlytic_bot" + }, + "Alpaca": { + "KeyId": "", + "SecretKey": "", + "IsPaper": true + } +} diff --git a/compose.yaml b/compose.yaml index 290afe3..42d7ef4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -111,6 +111,22 @@ services: volumes: - C:\Users\larsh\Documents\docker\finlytic\data\feedback:/app/data/feedback + finlyticbot: + image: finlyticbot + build: + context: . + dockerfile: FinlyticBot/Dockerfile + networks: + - postgres-network + environment: + - ConnectionStrings__DefaultConnection=Host=OmniDB;Database=finlytic_bot;Username=admin;Password=${DB_PASSWORD} + - MQTT__Host=host.docker.internal + - MQTT__Port=4545 + - MQTT__ClientId=finlytic_bot + - Alpaca__KeyId=${ALPACA_KEY_ID:-} + - Alpaca__SecretKey=${ALPACA_SECRET_KEY:-} + - Alpaca__IsPaper=true + finlyticbackend: image: finlyticbackend build: