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