refactor(bot): update paper trading models, broker integration, background services, and test project
This commit is contained in:
@@ -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<BotDbContext> options) : base(options) { }
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public BotDbContext(DbContextOptions<BotDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||
public DbSet<ExecutedPaperTradeEntity> ExecutedPaperTrades => Set<ExecutedPaperTradeEntity>();
|
||||
public DbSet<BotAuditLogEntity> BotAuditLogs => Set<BotAuditLogEntity>();
|
||||
public DbSet<BotPositionEntity> Positions => Set<BotPositionEntity>();
|
||||
public DbSet<BotPortfolioSnapshotEntity> PortfolioSnapshots => Set<BotPortfolioSnapshotEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// 1. Settings Table
|
||||
modelBuilder.Entity<SettingEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Key).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ExecutedPaperTradeEntity>(entity =>
|
||||
// 2. ExitPlan JSONB Converter
|
||||
var exitPlanConverter = new ValueConverter<ExitPlan, string>(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<ExitPlan>(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List<TakeProfitStage>(), null, null, null, null)
|
||||
);
|
||||
|
||||
// 3. Bot Positions Table
|
||||
modelBuilder.Entity<BotPositionEntity>(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<BotAuditLogEntity>(entity =>
|
||||
// 4. Portfolio Snapshots Table
|
||||
modelBuilder.Entity<BotPortfolioSnapshotEntity>(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<BotDbContext>
|
||||
{
|
||||
public BotDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<BotDbContext>();
|
||||
optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_bot;Username=postgres;Password=postgres");
|
||||
return new BotDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+10
-4
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,24 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Alpaca.Markets" Version="7.2.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Alpaca.Markets" Version="7.2.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticBot.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitBotDatabase : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BotAuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TradeId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Action = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
IsAccepted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
CalculatedSize = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||
AccountEquity = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||
Timestamp = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
LastUpdatedUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
TradeId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Isin = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
AlpacaOrderId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Side = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
SignalEntryPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
ActualFillPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||
StopLossPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
TakeProfitPrice1 = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
TakeProfitPrice2 = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||
CalculatedCrv = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
WinRate = table.Column<double>(type: "double precision", nullable: false),
|
||||
SlippagePercent = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||
RealizedPnl = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
RealizedPnlPercent = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
RejectReason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
PlacedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
FilledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BotAuditLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DynamicSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExecutedPaperTrades");
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
-102
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("AccountEquity")
|
||||
b.Property<decimal>("CashEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<decimal?>("CalculatedSize")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<bool>("IsAccepted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TradeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
b.Property<decimal>("DailyRealizedPnlEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("OpenPositionsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("SnapshotDateUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("TotalEquityEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("TotalUnrealizedPnlEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("ActualFillPrice")
|
||||
b.Property<string>("AlpacaOrderId")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<decimal>("AverageBuyIn")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<Guid?>("AlpacaOrderId")
|
||||
.HasColumnType("uuid");
|
||||
b.Property<string>("ClientOrderId")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<decimal>("CalculatedCrv")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<DateTime?>("ClosedAt")
|
||||
b.Property<DateTime?>("ClosedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
b.Property<decimal>("CurrentPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("CurrentStopLoss")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("Direction")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("EntryPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("ExitPlan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime?>("FilledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
b.Property<decimal>("InitialStopLoss")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime>("PlacedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("RealizedPnl")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("RealizedPnlPercent")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("RejectReason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("Side")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<decimal>("SignalEntryPrice")
|
||||
b.Property<DateTime>("LastSyncAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("OpenedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("ProposalId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("SlippagePercent")
|
||||
b.Property<decimal>("RealizedPnlEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<decimal>("StopLossPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<decimal>("TakeProfitPrice1")
|
||||
b.Property<decimal>("TakeProfit1")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("TakeProfitPrice2")
|
||||
b.Property<decimal>("TakeProfit2")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("TradeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
b.Property<decimal>("TotalFeesEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
b.Property<int>("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 =>
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticBot.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialBotMigration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bot_portfolio_snapshots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SnapshotDateUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
TotalEquityEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
CashEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
OpenPositionsCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DailyRealizedPnlEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
TotalUnrealizedPnlEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
WinRatePercent = table.Column<decimal>(type: "numeric(6,2)", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Venue = table.Column<int>(type: "integer", nullable: false),
|
||||
AlpacaOrderId = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: true),
|
||||
ClientOrderId = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: true),
|
||||
Direction = table.Column<int>(type: "integer", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
EntryPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
AverageBuyIn = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
InitialStopLoss = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
CurrentStopLoss = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
CurrentPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
TakeProfit1 = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
TakeProfit2 = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
RealizedPnlEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
TotalFeesEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
ExitPlan = table.Column<string>(type: "jsonb", nullable: false),
|
||||
OpenedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ClosedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
LastSyncAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
LastUpdatedUtc = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "bot_portfolio_snapshots");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "bot_positions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DynamicSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("AccountEquity")
|
||||
b.Property<decimal>("CashEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<decimal?>("CalculatedSize")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<bool>("IsAccepted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TradeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
b.Property<decimal>("DailyRealizedPnlEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("OpenPositionsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("SnapshotDateUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("TotalEquityEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("TotalUnrealizedPnlEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("ActualFillPrice")
|
||||
b.Property<string>("AlpacaOrderId")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<decimal>("AverageBuyIn")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<Guid?>("AlpacaOrderId")
|
||||
.HasColumnType("uuid");
|
||||
b.Property<string>("ClientOrderId")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<decimal>("CalculatedCrv")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<DateTime?>("ClosedAt")
|
||||
b.Property<DateTime?>("ClosedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
b.Property<decimal>("CurrentPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("CurrentStopLoss")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("Direction")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("EntryPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("ExitPlan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime?>("FilledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
b.Property<decimal>("InitialStopLoss")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime>("PlacedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("RealizedPnl")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("RealizedPnlPercent")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("RejectReason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("Side")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<decimal>("SignalEntryPrice")
|
||||
b.Property<DateTime>("LastSyncAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("OpenedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("ProposalId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("SlippagePercent")
|
||||
b.Property<decimal>("RealizedPnlEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<decimal>("StopLossPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<decimal>("TakeProfitPrice1")
|
||||
b.Property<decimal>("TakeProfit1")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("TakeProfitPrice2")
|
||||
b.Property<decimal>("TakeProfit2")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("TradeId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
b.Property<decimal>("TotalFeesEur")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
b.Property<int>("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 =>
|
||||
|
||||
+25
-14
@@ -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<BotDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<BotDbContext>());
|
||||
|
||||
// 2. Core Services
|
||||
// 2. Register Core Services & Logger
|
||||
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
||||
|
||||
// 3. Domain Services
|
||||
builder.Services.AddSingleton<IBotRiskSizingService, BotRiskSizingService>();
|
||||
builder.Services.AddSingleton<IAlpacaBrokerService, AlpacaBrokerService>();
|
||||
builder.Services.AddSingleton<IBotOrderExecutionService, BotOrderExecutionService>();
|
||||
// 3. Register Alpaca Trading Service & Synthetic Broker
|
||||
builder.Services.AddSingleton<IAlpacaTradingService, AlpacaPaperTradingService>();
|
||||
builder.Services.AddSingleton<ISyntheticPaperBroker, SyntheticPaperBroker>();
|
||||
builder.Services.AddSingleton<IBotOrderExecutor, BotOrderExecutor>();
|
||||
|
||||
// 4. Hosted Services
|
||||
// 4. Register MQTT Client & RPC Bridge
|
||||
builder.Services.AddSingleton<BotMqttClient>();
|
||||
builder.Services.AddSingleton<IBotRpcClient>(sp => sp.GetRequiredService<BotMqttClient>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<BotMqttClient>());
|
||||
builder.Services.AddHostedService<AlpacaWebSocketMonitorWorker>();
|
||||
|
||||
// 5. Register Background Services
|
||||
builder.Services.AddHostedService<EngineProposalConsumerBackgroundService>();
|
||||
builder.Services.AddHostedService<BotTradeLifecycleBackgroundService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
// Run DB Migrations
|
||||
// Run startup database migrations
|
||||
using (var scope = host.Services.CreateScope())
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AlpacaPaperTradingService> _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<AlpacaPaperTradingService> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<IAlpacaTradingClient?> 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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liquidates the entire open position for <paramref name="symbol"/> at market price using Alpaca's
|
||||
/// native <c>DELETE /v2/positions/{symbol}</c> endpoint (<see cref="IAlpacaTradingClient.DeletePositionAsync"/>).
|
||||
/// Unlike <see cref="PlaceBracketOrderAsync"/> (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
|
||||
/// <c>FinlyticBot.Util.BotMqttClient</c>'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.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Alpaca is not configured/reachable — no liquidation was attempted.</exception>
|
||||
public async Task<AlpacaPositionCloseResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ruft die echte Alpaca-Kontoübersicht ab. Wirft eine <see cref="InvalidOperationException"/>,
|
||||
/// 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 <see cref="FinlyticCore.Dtos.Bot.BotExecutionVenue.SyntheticPaperBroker"/>
|
||||
/// als Venue wählen und den dedizierten Ledger (<c>ISyntheticPaperBroker</c>) verwenden.
|
||||
/// </summary>
|
||||
public async Task<AccountSummaryDto> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a confirmed Alpaca position liquidation (<see cref="IAlpacaTradingService.ClosePositionAsync"/>).
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="OrderId">The Alpaca order ID of the liquidation (market) order.</param>
|
||||
/// <param name="OrderStatus">The Alpaca order status returned immediately after submission (e.g. "Accepted", "Filled").</param>
|
||||
/// <param name="AverageFillPrice">
|
||||
/// The average fill price if Alpaca already reports one at submission time; <see langword="null"/> 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 <see langword="null"/> as zero.
|
||||
/// </param>
|
||||
public record AlpacaPositionCloseResult(string OrderId, string OrderStatus, decimal? AverageFillPrice);
|
||||
|
||||
public interface IAlpacaTradingService
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task<string> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Liquidates the entire open position for <paramref name="symbol"/> 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.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Alpaca is not configured/reachable.</exception>
|
||||
Task<AlpacaPositionCloseResult> ClosePositionAsync(
|
||||
string symbol,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AccountSummaryDto> GetPortfolioSummaryAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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<AlpacaBrokerService> _finlyticLogger;
|
||||
|
||||
private IAlpacaTradingClient? _cachedClient;
|
||||
private string _lastInitKey = string.Empty;
|
||||
|
||||
public AlpacaBrokerService(
|
||||
ISettingsService settingsService,
|
||||
IConfiguration configuration,
|
||||
IFinlyticLogger<AlpacaBrokerService> finlyticLogger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_configuration = configuration;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
private async Task<IAlpacaTradingClient?> 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<BrokerAccountInfo?> 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<IAsset?> 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<IClock?> 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<IOrder?> 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<bool> 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<IReadOnlyList<IOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return Array.Empty<IOrder>();
|
||||
|
||||
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<IOrder>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Alpaca.Markets;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Util;
|
||||
using FinlyticCore.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public class AlpacaWebSocketMonitorWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly BotMqttClient _mqttClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IFinlyticLogger<AlpacaWebSocketMonitorWorker> _finlyticLogger;
|
||||
|
||||
private IAlpacaStreamingClient? _streamingClient;
|
||||
|
||||
public AlpacaWebSocketMonitorWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ISettingsService settingsService,
|
||||
BotMqttClient mqttClient,
|
||||
IConfiguration configuration,
|
||||
IFinlyticLogger<AlpacaWebSocketMonitorWorker> 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<bool>();
|
||||
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<BotDbContext>();
|
||||
|
||||
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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Alpaca.Markets;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Entities;
|
||||
using FinlyticBot.Util;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public interface IBotOrderExecutionService
|
||||
{
|
||||
Task ProcessTradeProposalAsync(TradeProposalDto proposal, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class BotOrderExecutionService : IBotOrderExecutionService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAlpacaBrokerService _brokerService;
|
||||
private readonly IBotRiskSizingService _sizingService;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<BotOrderExecutionService> _finlyticLogger;
|
||||
|
||||
public BotOrderExecutionService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAlpacaBrokerService brokerService,
|
||||
IBotRiskSizingService sizingService,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<BotOrderExecutionService> 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<BotDbContext>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticBot.Util;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public class BotRiskSizingService : IBotRiskSizingService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<BotRiskSizingService> _finlyticLogger;
|
||||
|
||||
public BotRiskSizingService(
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<BotRiskSizingService> finlyticLogger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
public async Task<SizingResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticBot.Services.Execution;
|
||||
using FinlyticBot.Settings;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticBot.Services.Consumers;
|
||||
|
||||
public class EngineProposalConsumerBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<EngineProposalConsumerBackgroundService> _logger;
|
||||
|
||||
public static Action<TradeProposalDto>? OnProposalReceived;
|
||||
|
||||
public EngineProposalConsumerBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<EngineProposalConsumerBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
OnProposalReceived = async (proposal) =>
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) return;
|
||||
|
||||
try
|
||||
{
|
||||
var autoExec = await _settingsService.GetSettingAsync(BotSettingKeys.EnableAutoExecution, stoppingToken);
|
||||
if (!autoExec)
|
||||
{
|
||||
await _logger.LogInfoAsync(BotSettingKeys.BotChannel,
|
||||
"[ProposalConsumer] Auto-execution is disabled. Ignoring proposal {Id} for {Isin}.",
|
||||
proposal.ProposalId, proposal.UnderlyingIsin);
|
||||
return;
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.BotChannel,
|
||||
"[ProposalConsumer] Consumed approved proposal {Id} for {Isin}. Executing paper trade...",
|
||||
proposal.ProposalId, proposal.UnderlyingIsin);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IBotOrderExecutor>();
|
||||
await executor.ExecuteProposalAsync(proposal, cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogErrorAsync(BotSettingKeys.BotChannel, ex,
|
||||
"[ProposalConsumer] Error executing trade proposal {Id}", proposal.ProposalId);
|
||||
}
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.Data.Common;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Database.Entities;
|
||||
using FinlyticBot.Services.Alpaca;
|
||||
using FinlyticBot.Services.Ledger;
|
||||
using FinlyticBot.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinlyticBot.Services.Execution;
|
||||
|
||||
public class BotOrderExecutor : IBotOrderExecutor
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAlpacaTradingService _alpacaService;
|
||||
private readonly ISyntheticPaperBroker _syntheticBroker;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<BotOrderExecutor> _logger;
|
||||
|
||||
public BotOrderExecutor(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAlpacaTradingService alpacaService,
|
||||
ISyntheticPaperBroker syntheticBroker,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<BotOrderExecutor> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_alpacaService = alpacaService;
|
||||
_syntheticBroker = syntheticBroker;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BotTradeOrderDto?> ExecuteProposalAsync(
|
||||
TradeProposalDto proposal,
|
||||
BotExecutionVenue? preferredVenue = null,
|
||||
decimal? customQuantity = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (proposal == null || string.IsNullOrWhiteSpace(proposal.UnderlyingIsin)) return null;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
// 1. Risk Gate: Check active positions count
|
||||
int maxPositions = await _settingsService.GetSettingAsync(BotSettingKeys.MaxConcurrentPositions, cancellationToken);
|
||||
int activeCount = await db.Positions.CountAsync(
|
||||
p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered,
|
||||
cancellationToken);
|
||||
|
||||
if (activeCount >= maxPositions)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.BotChannel,
|
||||
"[BotExecutor] Risk Gate rejected proposal {ProposalId}: Max concurrent positions ({Max}) reached (Active: {Active}).",
|
||||
proposal.ProposalId, maxPositions, activeCount);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Risk Gate: Calculate dynamic sizing (1-2% Rule based on Account Equity and Stop-Loss distance)
|
||||
decimal riskPerTradePct = await _settingsService.GetSettingAsync(BotSettingKeys.RiskPerTradePercent, cancellationToken);
|
||||
if (riskPerTradePct <= 0m) riskPerTradePct = 1.0m;
|
||||
|
||||
decimal maxAllocationPct = await _settingsService.GetSettingAsync(BotSettingKeys.MaxPositionAllocationPercent, cancellationToken);
|
||||
if (maxAllocationPct <= 0m) maxAllocationPct = 20.0m;
|
||||
|
||||
// Fetch current total account equity (fällt auf das konfigurierte synthetische Startkapital
|
||||
// zurück, falls der Ledger-Abruf fehlschlägt - dieselbe Quelle wie SyntheticPaperBroker.GetSummaryAsync).
|
||||
decimal totalEquity = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken);
|
||||
try
|
||||
{
|
||||
var summary = await _syntheticBroker.GetSummaryAsync(cancellationToken);
|
||||
if (summary?.Equity > 0)
|
||||
{
|
||||
totalEquity = summary.Equity;
|
||||
}
|
||||
}
|
||||
catch (DbException ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex,
|
||||
"[BotExecutor] Failed to fetch synthetic ledger summary from database. Falling back to configured base capital ({BaseCapital:F2} €).",
|
||||
totalEquity);
|
||||
}
|
||||
|
||||
decimal maxRiskCapital = totalEquity * (riskPerTradePct / 100.0m);
|
||||
decimal maxPositionCapital = totalEquity * (maxAllocationPct / 100.0m);
|
||||
|
||||
decimal quantity = customQuantity ?? 1m;
|
||||
if (!customQuantity.HasValue && proposal.EntryPrice > 0)
|
||||
{
|
||||
decimal unitRisk = Math.Abs(proposal.EntryPrice - proposal.InvalidationPrice);
|
||||
if (unitRisk > 0)
|
||||
{
|
||||
// Dynamic 1-2% rule: Quantity = MaxRiskCapital / UnitRisk
|
||||
decimal calculatedQty = maxRiskCapital / unitRisk;
|
||||
|
||||
// Safeguard: Never allocate more than maxPositionCapital to a single position
|
||||
decimal maxQtyByCapital = maxPositionCapital / proposal.EntryPrice;
|
||||
if (calculatedQty > maxQtyByCapital)
|
||||
{
|
||||
calculatedQty = maxQtyByCapital;
|
||||
}
|
||||
|
||||
quantity = Math.Max(1m, Math.Round(calculatedQty, 0));
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.BotChannel,
|
||||
"[BotExecutor] Dynamic Sizing (1-2% Rule): Equity={Equity:F2} €, RiskPct={RiskPct}%, MaxRisk={RiskCap:F2} €, UnitRisk={UnitRisk:F2} € => Quantity={Qty} (Max Alloc: {MaxCap:F2} €)",
|
||||
totalEquity, riskPerTradePct, maxRiskCapital, unitRisk, quantity, maxPositionCapital);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback if stop loss is invalid: allocate 5% of equity
|
||||
decimal fallbackCapital = totalEquity * 0.05m;
|
||||
quantity = Math.Max(1m, Math.Round(fallbackCapital / proposal.EntryPrice, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Venue Decision
|
||||
BotExecutionVenue venue = preferredVenue ?? BotExecutionVenue.SyntheticPaperBroker;
|
||||
bool isUsEquities = proposal.UnderlyingIsin.StartsWith("US", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(proposal.Symbol);
|
||||
|
||||
if (!preferredVenue.HasValue)
|
||||
{
|
||||
venue = (isUsEquities && _alpacaService.IsConfigured && proposal.SelectedDerivative == null)
|
||||
? BotExecutionVenue.AlpacaPaperTrading
|
||||
: BotExecutionVenue.SyntheticPaperBroker;
|
||||
}
|
||||
|
||||
decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0
|
||||
? proposal.ExitPlan.TakeProfitStages[0].TargetPrice
|
||||
: (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.05m : proposal.EntryPrice * 0.95m);
|
||||
|
||||
decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1
|
||||
? proposal.ExitPlan.TakeProfitStages[1].TargetPrice
|
||||
: (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.10m : proposal.EntryPrice * 0.90m);
|
||||
|
||||
BotPositionEntity positionEntity;
|
||||
|
||||
if (venue == BotExecutionVenue.AlpacaPaperTrading)
|
||||
{
|
||||
try
|
||||
{
|
||||
string alpacaOrderId = await _alpacaService.PlaceBracketOrderAsync(
|
||||
proposal.Symbol,
|
||||
proposal.Direction,
|
||||
(int)quantity,
|
||||
proposal.EntryPrice,
|
||||
proposal.InvalidationPrice,
|
||||
takeProfit1,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
positionEntity = new BotPositionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.ProposalId,
|
||||
Isin = proposal.UnderlyingIsin,
|
||||
Symbol = proposal.Symbol,
|
||||
Venue = BotExecutionVenue.AlpacaPaperTrading,
|
||||
AlpacaOrderId = alpacaOrderId,
|
||||
ClientOrderId = $"ALP_{Guid.NewGuid():N}",
|
||||
Direction = proposal.Direction,
|
||||
Quantity = quantity,
|
||||
EntryPrice = proposal.EntryPrice,
|
||||
AverageBuyIn = proposal.EntryPrice,
|
||||
InitialStopLoss = proposal.InvalidationPrice,
|
||||
CurrentStopLoss = proposal.InvalidationPrice,
|
||||
CurrentPrice = proposal.EntryPrice,
|
||||
TakeProfit1 = takeProfit1,
|
||||
TakeProfit2 = takeProfit2,
|
||||
TotalFeesEur = 0m, // Alpaca zero commission paper
|
||||
RealizedPnlEur = 0m,
|
||||
Status = BotPositionStatus.Active,
|
||||
ExitPlan = proposal.ExitPlan,
|
||||
OpenedAtUtc = DateTime.UtcNow,
|
||||
LastSyncAtUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Positions.Add(positionEntity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex,
|
||||
"[BotExecutor] Alpaca order placement failed for {Symbol}. Falling back to Synthetic Broker.", proposal.Symbol);
|
||||
positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken);
|
||||
}
|
||||
|
||||
return MapEntityToDto(positionEntity);
|
||||
}
|
||||
|
||||
public static BotTradeOrderDto MapEntityToDto(BotPositionEntity e)
|
||||
{
|
||||
decimal unrealizedPnl = 0m;
|
||||
if (e.AverageBuyIn > 0 && e.Quantity > 0 && e.CurrentPrice > 0)
|
||||
{
|
||||
unrealizedPnl = e.Direction == SignalDirection.Buy
|
||||
? (e.CurrentPrice - e.AverageBuyIn) * e.Quantity
|
||||
: (e.AverageBuyIn - e.CurrentPrice) * e.Quantity;
|
||||
}
|
||||
|
||||
return new BotTradeOrderDto(
|
||||
OrderId: e.Id,
|
||||
ProposalId: e.ProposalId,
|
||||
Isin: e.Isin,
|
||||
Symbol: e.Symbol,
|
||||
Venue: e.Venue,
|
||||
AlpacaOrderId: e.AlpacaOrderId,
|
||||
ClientOrderId: e.ClientOrderId,
|
||||
Direction: e.Direction,
|
||||
RequestedQuantity: e.Quantity,
|
||||
FilledQuantity: e.Quantity,
|
||||
EntryPrice: e.EntryPrice,
|
||||
AverageBuyIn: e.AverageBuyIn,
|
||||
InitialStopLoss: e.InitialStopLoss,
|
||||
CurrentStopLoss: e.CurrentStopLoss,
|
||||
TakeProfit1: e.TakeProfit1,
|
||||
TakeProfit2: e.TakeProfit2,
|
||||
CurrentPrice: e.CurrentPrice,
|
||||
UnrealizedPnlEur: Math.Round(unrealizedPnl, 2),
|
||||
RealizedPnlEur: e.RealizedPnlEur,
|
||||
Status: e.Status,
|
||||
ExitPlan: e.ExitPlan,
|
||||
CreatedAtUtc: e.OpenedAtUtc,
|
||||
FilledAtUtc: e.OpenedAtUtc,
|
||||
ClosedAtUtc: e.ClosedAtUtc
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
|
||||
namespace FinlyticBot.Services.Execution;
|
||||
|
||||
public interface IBotOrderExecutor
|
||||
{
|
||||
Task<BotTradeOrderDto?> ExecuteProposalAsync(
|
||||
TradeProposalDto proposal,
|
||||
BotExecutionVenue? preferredVenue = null,
|
||||
decimal? customQuantity = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Alpaca.Markets;
|
||||
using FinlyticCore.Models.Trades;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public record BrokerAccountInfo(
|
||||
decimal Equity,
|
||||
decimal BuyingPower,
|
||||
decimal Cash,
|
||||
string Currency,
|
||||
bool IsBlocked
|
||||
);
|
||||
|
||||
public interface IAlpacaBrokerService
|
||||
{
|
||||
Task<BrokerAccountInfo?> GetAccountInfoAsync(CancellationToken ct = default);
|
||||
Task<IAsset?> GetAssetAsync(string symbol, CancellationToken ct = default);
|
||||
Task<IClock?> GetMarketClockAsync(CancellationToken ct = default);
|
||||
Task<IOrder?> PlaceBracketOrderAsync(TradeProposalDto proposal, decimal quantity, string orderType = "Limit", CancellationToken ct = default);
|
||||
Task<bool> CancelOrderAsync(Guid orderId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<IOrder>> GetOpenOrdersAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Trades;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public record SizingResult(
|
||||
bool IsApproved,
|
||||
string? RejectReason,
|
||||
decimal Quantity,
|
||||
decimal TotalPositionValue,
|
||||
decimal RiskAmount,
|
||||
decimal CalculatedCrv
|
||||
);
|
||||
|
||||
public interface IBotRiskSizingService
|
||||
{
|
||||
Task<SizingResult> EvaluateAndSizeTradeAsync(
|
||||
TradeProposalDto proposal,
|
||||
decimal accountEquity,
|
||||
int currentOpenTradesCount,
|
||||
decimal todayRealizedLossPercent,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticBot.Database.Entities;
|
||||
|
||||
namespace FinlyticBot.Services.Ledger;
|
||||
|
||||
public interface ISyntheticPaperBroker
|
||||
{
|
||||
Task<BotPositionEntity> OpenPositionAsync(
|
||||
TradeProposalDto proposal,
|
||||
decimal quantity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BotPositionEntity> ClosePositionAsync(
|
||||
Guid positionId,
|
||||
decimal exitPrice,
|
||||
BotPositionStatus exitStatus,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AccountSummaryDto> GetSummaryAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Database.Entities;
|
||||
using FinlyticBot.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinlyticBot.Services.Ledger;
|
||||
|
||||
public class SyntheticPaperBroker : ISyntheticPaperBroker
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<SyntheticPaperBroker> _logger;
|
||||
|
||||
public SyntheticPaperBroker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<SyntheticPaperBroker> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BotPositionEntity> OpenPositionAsync(
|
||||
TradeProposalDto proposal,
|
||||
decimal quantity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
decimal entryPrice = proposal.EntryPrice;
|
||||
decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0
|
||||
? proposal.ExitPlan.TakeProfitStages[0].TargetPrice
|
||||
: (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.05m : entryPrice * 0.95m);
|
||||
|
||||
decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1
|
||||
? proposal.ExitPlan.TakeProfitStages[1].TargetPrice
|
||||
: (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.10m : entryPrice * 0.90m);
|
||||
|
||||
var position = new BotPositionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.ProposalId,
|
||||
Isin = proposal.UnderlyingIsin,
|
||||
Symbol = proposal.Symbol,
|
||||
Venue = BotExecutionVenue.SyntheticPaperBroker,
|
||||
ClientOrderId = $"SYN_{Guid.NewGuid():N}",
|
||||
Direction = proposal.Direction,
|
||||
Quantity = quantity,
|
||||
EntryPrice = entryPrice,
|
||||
AverageBuyIn = entryPrice,
|
||||
InitialStopLoss = proposal.InvalidationPrice,
|
||||
CurrentStopLoss = proposal.InvalidationPrice,
|
||||
CurrentPrice = entryPrice,
|
||||
TakeProfit1 = takeProfit1,
|
||||
TakeProfit2 = takeProfit2,
|
||||
TotalFeesEur = 1.00m,
|
||||
RealizedPnlEur = 0m,
|
||||
Status = BotPositionStatus.Active,
|
||||
ExitPlan = proposal.ExitPlan,
|
||||
OpenedAtUtc = DateTime.UtcNow,
|
||||
LastSyncAtUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Positions.Add(position);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel,
|
||||
"[SyntheticBroker] Opened position {Id} for {Isin} ({Symbol}) at {Entry:F2} € (Qty: {Qty}, SL: {SL:F2}, TP1: {TP1:F2})",
|
||||
position.Id, position.Isin, position.Symbol, position.EntryPrice, position.Quantity, position.CurrentStopLoss, position.TakeProfit1);
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
public async Task<BotPositionEntity> ClosePositionAsync(
|
||||
Guid positionId,
|
||||
decimal exitPrice,
|
||||
BotPositionStatus exitStatus,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
var pos = await db.Positions.FirstOrDefaultAsync(p => p.Id == positionId, cancellationToken);
|
||||
if (pos == null) throw new InvalidOperationException($"Position {positionId} not found.");
|
||||
|
||||
pos.Status = exitStatus;
|
||||
pos.ClosedAtUtc = DateTime.UtcNow;
|
||||
pos.CurrentPrice = exitPrice;
|
||||
pos.LastSyncAtUtc = DateTime.UtcNow;
|
||||
pos.TotalFeesEur += 1.00m; // Exit fee
|
||||
|
||||
if (exitStatus == BotPositionStatus.KnockedOut)
|
||||
{
|
||||
pos.RealizedPnlEur = -((pos.AverageBuyIn * pos.Quantity) + pos.TotalFeesEur);
|
||||
}
|
||||
else
|
||||
{
|
||||
decimal pnl = pos.Direction == SignalDirection.Buy
|
||||
? ((exitPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
|
||||
: ((pos.AverageBuyIn - exitPrice) * pos.Quantity) - pos.TotalFeesEur;
|
||||
|
||||
pos.RealizedPnlEur = Math.Round(pnl, 2);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel,
|
||||
"[SyntheticBroker] Closed position {Id} at {Exit:F2} € with status {Status} (PnL: {PnL:F2} €)",
|
||||
pos.Id, exitPrice, exitStatus, pos.RealizedPnlEur);
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
public async Task<AccountSummaryDto> GetSummaryAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken);
|
||||
var positions = await db.Positions.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
decimal totalRealized = positions.Sum(p => p.RealizedPnlEur);
|
||||
decimal totalFees = positions.Sum(p => p.TotalFeesEur);
|
||||
|
||||
var openPositions = positions
|
||||
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered)
|
||||
.ToList();
|
||||
|
||||
// Unrealized P&L of still-open positions (direction-aware: a short position gains when
|
||||
// CurrentPrice drops below AverageBuyIn). CurrentPrice is kept fresh by
|
||||
// BotTradeLifecycleBackgroundService, which re-fetches the latest candle close for every open
|
||||
// position on each monitoring tick. Without this term, equity only ever moved when a position
|
||||
// closed, even though open positions were already sitting on real gains/losses.
|
||||
decimal unrealizedPnl = openPositions.Sum(p => p.Direction == SignalDirection.Buy
|
||||
? (p.CurrentPrice - p.AverageBuyIn) * p.Quantity
|
||||
: (p.AverageBuyIn - p.CurrentPrice) * p.Quantity);
|
||||
|
||||
decimal currentEquity = baseCapital + totalRealized + unrealizedPnl;
|
||||
decimal invested = openPositions.Sum(p => p.AverageBuyIn * p.Quantity);
|
||||
|
||||
// Cash is equity minus the capital tied up in open positions at cost (AverageBuyIn), i.e. the
|
||||
// portion of the ledger not currently committed to a position - unrealized gains/losses on open
|
||||
// positions are reflected in `currentEquity` above but not in `cash` until the position closes.
|
||||
decimal cash = Math.Max(0m, currentEquity - invested);
|
||||
|
||||
// BuyingPower = cash * 2.0 is a deliberate simplification (flat 2x leverage assumption for this
|
||||
// internal synthetic paper broker), not a real margin/buying-power calculation from a broker API.
|
||||
return new AccountSummaryDto(
|
||||
Equity: Math.Round(currentEquity, 2),
|
||||
Cash: Math.Round(cash, 2),
|
||||
BuyingPower: Math.Round(cash * 2.0m, 2),
|
||||
Currency: "EUR",
|
||||
Status: "Active"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Database.Entities;
|
||||
using FinlyticBot.Services.Alpaca;
|
||||
using FinlyticBot.Services.Execution;
|
||||
using FinlyticBot.Services.Ledger;
|
||||
using FinlyticBot.Services.Mqtt;
|
||||
using FinlyticBot.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticBot.Services.Monitoring;
|
||||
|
||||
public record BotGetCandlesRequest(string Isin, string Timeframe = "1m");
|
||||
|
||||
public class BotTradeLifecycleBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAlpacaTradingService _alpacaService;
|
||||
private readonly ISyntheticPaperBroker _syntheticBroker;
|
||||
private readonly IBotRpcClient _rpcClient;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<BotTradeLifecycleBackgroundService> _logger;
|
||||
|
||||
public BotTradeLifecycleBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAlpacaTradingService alpacaService,
|
||||
ISyntheticPaperBroker syntheticBroker,
|
||||
IBotRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<BotTradeLifecycleBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_alpacaService = alpacaService;
|
||||
_syntheticBroker = syntheticBroker;
|
||||
_rpcClient = rpcClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Starting Bot Trade Lifecycle & Trailing Monitoring Service...");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
DateTime lastSnapshotUtc = DateTime.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var intervalSec = await _settingsService.GetSettingAsync(BotSettingKeys.MonitoringIntervalSeconds, stoppingToken);
|
||||
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
var openPositions = await db.Positions
|
||||
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (openPositions.Count > 0)
|
||||
{
|
||||
foreach (var pos in openPositions)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Fetch live candle for price
|
||||
var candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, BotGetCandlesRequest>(
|
||||
"ta_GetCandles",
|
||||
new BotGetCandlesRequest(pos.Isin, "1m"),
|
||||
TimeSpan.FromSeconds(3)
|
||||
);
|
||||
|
||||
if (candles == null || candles.Count == 0) continue;
|
||||
|
||||
var lastCandle = candles.Last();
|
||||
decimal currentPrice = lastCandle.Close;
|
||||
pos.CurrentPrice = currentPrice;
|
||||
pos.LastSyncAtUtc = DateTime.UtcNow;
|
||||
|
||||
// 2. Check Stop-Loss Violation
|
||||
bool isStopped = pos.Direction == SignalDirection.Buy
|
||||
? currentPrice <= pos.CurrentStopLoss
|
||||
: currentPrice >= pos.CurrentStopLoss;
|
||||
|
||||
if (isStopped)
|
||||
{
|
||||
pos.Status = BotPositionStatus.StoppedOut;
|
||||
pos.ClosedAtUtc = DateTime.UtcNow;
|
||||
decimal pnl = pos.Direction == SignalDirection.Buy
|
||||
? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
|
||||
: ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur;
|
||||
pos.RealizedPnlEur = Math.Round(pnl, 2);
|
||||
|
||||
await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Position {Id} for {Isin} STOPPED OUT at {Price:F2} € (PnL: {PnL:F2} €)",
|
||||
pos.Id, pos.Isin, currentPrice, pos.RealizedPnlEur);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Check Take-Profit 1 -> Move SL to Break-Even (Free-Roll)
|
||||
bool isTp1 = pos.Direction == SignalDirection.Buy
|
||||
? currentPrice >= pos.TakeProfit1
|
||||
: currentPrice <= pos.TakeProfit1;
|
||||
|
||||
if (isTp1 && pos.Status == BotPositionStatus.Active)
|
||||
{
|
||||
decimal oldSl = pos.CurrentStopLoss;
|
||||
pos.CurrentStopLoss = pos.AverageBuyIn;
|
||||
pos.Status = BotPositionStatus.BreakEvenTriggered;
|
||||
|
||||
if (pos.Venue == BotExecutionVenue.AlpacaPaperTrading && !string.IsNullOrWhiteSpace(pos.AlpacaOrderId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _alpacaService.UpdateStopLossAsync(pos.AlpacaOrderId, pos.CurrentStopLoss, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex,
|
||||
"[BotLifecycle] Failed to update Alpaca bracket stop-loss for order {Id}", pos.AlpacaOrderId);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Position {Id} for {Isin} reached TP1 ({TP1:F2} €). Moved SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)",
|
||||
pos.Id, pos.Isin, pos.TakeProfit1, oldSl, pos.AverageBuyIn);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
|
||||
// 4. Check Take-Profit 2
|
||||
bool isTp2 = pos.Direction == SignalDirection.Buy
|
||||
? currentPrice >= pos.TakeProfit2
|
||||
: currentPrice <= pos.TakeProfit2;
|
||||
|
||||
if (isTp2)
|
||||
{
|
||||
pos.Status = BotPositionStatus.Closed;
|
||||
pos.ClosedAtUtc = DateTime.UtcNow;
|
||||
decimal pnl = pos.Direction == SignalDirection.Buy
|
||||
? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
|
||||
: ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur;
|
||||
pos.RealizedPnlEur = Math.Round(pnl, 2);
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Position {Id} for {Isin} reached TP2 ({TP2:F2} €). Closed with profit {PnL:F2} €",
|
||||
pos.Id, pos.Isin, pos.TakeProfit2, pos.RealizedPnlEur);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Trailing Stop Rule check
|
||||
if (pos.Status == BotPositionStatus.BreakEvenTriggered && pos.ExitPlan?.TrailingStopRule != null)
|
||||
{
|
||||
if (pos.Direction == SignalDirection.Buy)
|
||||
{
|
||||
decimal trail = currentPrice * 0.97m;
|
||||
if (trail > pos.CurrentStopLoss)
|
||||
{
|
||||
pos.CurrentStopLoss = Math.Round(trail, 2);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, ex,
|
||||
"[BotLifecycle] Error monitoring bot position {Id}", pos.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic Daily Snapshot
|
||||
if (DateTime.UtcNow - lastSnapshotUtc >= TimeSpan.FromHours(1))
|
||||
{
|
||||
var allPositions = await db.Positions.AsNoTracking().ToListAsync(stoppingToken);
|
||||
decimal realized = allPositions.Sum(p => p.RealizedPnlEur);
|
||||
decimal unrealized = allPositions
|
||||
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered)
|
||||
.Sum(p => (p.Direction == SignalDirection.Buy ? (p.CurrentPrice - p.AverageBuyIn) : (p.AverageBuyIn - p.CurrentPrice)) * p.Quantity);
|
||||
|
||||
int closedCount = allPositions.Count(p => p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut);
|
||||
int winCount = allPositions.Count(p => (p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut) && p.RealizedPnlEur > 0);
|
||||
decimal winRate = closedCount > 0 ? ((decimal)winCount / closedCount) * 100m : 0m;
|
||||
|
||||
// Same configured base capital as SyntheticPaperBroker.GetSummaryAsync (Rules.md §4:
|
||||
// no hardcoded financial constants) - this used to be a literal 50000m that could
|
||||
// silently drift from the actual configured Bot.SyntheticBaseCapitalEur setting.
|
||||
decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, stoppingToken);
|
||||
|
||||
db.PortfolioSnapshots.Add(new BotPortfolioSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SnapshotDateUtc = DateTime.UtcNow,
|
||||
TotalEquityEur = baseCapital + realized + unrealized,
|
||||
CashEur = baseCapital + realized,
|
||||
OpenPositionsCount = openPositions.Count,
|
||||
DailyRealizedPnlEur = realized,
|
||||
TotalUnrealizedPnlEur = unrealized,
|
||||
WinRatePercent = Math.Round(winRate, 2),
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
lastSnapshotUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogErrorAsync(BotSettingKeys.LifecycleChannel, ex,
|
||||
"[BotLifecycle] Unexpected error in bot lifecycle loop. Retrying in 15s.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
|
||||
"[BotLifecycle] Bot Trade Lifecycle Monitoring Service stopped.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FinlyticBot.Services.Mqtt;
|
||||
|
||||
public interface IBotRpcClient
|
||||
{
|
||||
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
|
||||
string channel,
|
||||
TRequest requestData,
|
||||
TimeSpan? timeout = null)
|
||||
where TResponse : class
|
||||
where TRequest : class;
|
||||
|
||||
Task PublishAsync<T>(string topic, T data, bool retain = false);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FinlyticCore.Models.Settings;
|
||||
|
||||
namespace FinlyticBot.Settings;
|
||||
|
||||
public static class BotSettingKeys
|
||||
{
|
||||
// --- Logging Channels ---
|
||||
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||
public static readonly SettingKey<bool> BotChannel = new("Logging.Channel.Bot", true);
|
||||
public static readonly SettingKey<bool> AlpacaChannel = new("Logging.Channel.Alpaca", true);
|
||||
public static readonly SettingKey<bool> LedgerChannel = new("Logging.Channel.Ledger", true);
|
||||
public static readonly SettingKey<bool> LifecycleChannel = new("Logging.Channel.Lifecycle", true);
|
||||
|
||||
// --- Alpaca Credentials (Admin Panel konfigurierbar) ---
|
||||
public static readonly SettingKey<string> AlpacaKeyId = new("Alpaca.KeyId", "");
|
||||
public static readonly SettingKey<string> AlpacaSecretKey = new("Alpaca.SecretKey", "");
|
||||
public static readonly SettingKey<bool> AlpacaIsPaper = new("Alpaca.IsPaper", true);
|
||||
|
||||
// --- Risk & Dynamic Sizing Rules (1-2% Regel) ---
|
||||
public static readonly SettingKey<bool> EnableAutoExecution = new("Bot.EnableAutoExecution", true);
|
||||
public static readonly SettingKey<decimal> RiskPerTradePercent = new("Bot.RiskPerTradePercent", 1.0m); // Standard 1.0% (1-2% Regel)
|
||||
public static readonly SettingKey<decimal> MaxPositionAllocationPercent = new("Bot.MaxPositionAllocationPercent", 20.0m); // Max 20% des Gesamtkapitals pro Position
|
||||
public static readonly SettingKey<int> MaxConcurrentPositions = new("Bot.MaxConcurrentPositions", 5);
|
||||
public static readonly SettingKey<decimal> DailyLossLimitPercent = new("Bot.DailyLossLimitPercent", 3.0m);
|
||||
public static readonly SettingKey<int> MonitoringIntervalSeconds = new("Bot.MonitoringIntervalSeconds", 15);
|
||||
|
||||
// --- Synthetic Paper Broker: Startkapital des internen Ledgers (EUR) ---
|
||||
public static readonly SettingKey<decimal> SyntheticBaseCapitalEur = new("Bot.SyntheticBaseCapitalEur", 50000.0m);
|
||||
}
|
||||
+301
-106
@@ -1,15 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticBot.Services;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.Settings;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Models;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Util;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Database.Entities;
|
||||
using FinlyticBot.Services.Alpaca;
|
||||
using FinlyticBot.Services.Consumers;
|
||||
using FinlyticBot.Services.Execution;
|
||||
using FinlyticBot.Services.Ledger;
|
||||
using FinlyticBot.Services.Mqtt;
|
||||
using FinlyticBot.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -17,176 +28,360 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticBot.Util;
|
||||
|
||||
public class BotMqttClient : ManagedMqttClient, IHostedService
|
||||
public class BotMqttClient : ManagedMqttClient, IHostedService, IBotRpcClient
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<BotMqttClient> _logger;
|
||||
private readonly IFinlyticLogger<BotMqttClient> _finlyticLogger;
|
||||
|
||||
public BotMqttClient(
|
||||
ILogger<BotMqttClient> logger,
|
||||
IConfiguration configuration,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BotMqttClient> logger) : base(logger)
|
||||
IFinlyticLogger<BotMqttClient> finlyticLogger) : base(logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var config = new MqttConfiguration
|
||||
{
|
||||
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
|
||||
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
|
||||
Username = _configuration["MQTT:Username"] ?? _configuration["MQTT__Username"],
|
||||
Password = _configuration["MQTT:Password"] ?? _configuration["MQTT__Password"],
|
||||
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_bot")}_{Guid.NewGuid():N}"
|
||||
};
|
||||
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticBot");
|
||||
|
||||
_logger.LogInformation("Starting FinlyticBot MQTT Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||
_logger.LogInformation("Starting FinlyticBot MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||
await _finlyticLogger.LogInfoAsync(BotSettingKeys.MqttChannel, "[BotMqttClient] Starting FinlyticBot MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||
await ConnectAsync(config);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping FinlyticBot MQTT Client.");
|
||||
_logger.LogInformation("Stopping FinlyticBot MQTT client.");
|
||||
// Broadcast via IFinlyticLogger too (see OnConnectedAsync's doc comment) so the live console shows a
|
||||
// clean "stopped" line instead of just silently going quiet.
|
||||
await _finlyticLogger.LogInfoAsync(BotSettingKeys.MqttChannel, "[BotMqttClient] Stopping FinlyticBot MQTT client.");
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnConnectedAsync()
|
||||
{
|
||||
_logger.LogInformation("FinlyticBot MQTT Client connected. Subscribing to topics...");
|
||||
_logger.LogInformation("FinlyticBot MQTT client connected. Registering RPC endpoints...");
|
||||
|
||||
await SubscribeAsync("services/events/trades/proposal");
|
||||
await SubscribeAsync("finlytic/trades/proposed/#");
|
||||
await SubscribeAsync("services/events/analyzer/trade_proposed");
|
||||
await SubscribeAsync("services/request/bot_Settings_GetAll/#");
|
||||
await SubscribeAsync("services/request/bot_Settings_Update/#");
|
||||
await SubscribeAsync("services/request/health_Ping/#");
|
||||
await SubscribeAsync(MqttTopics.ResponseWildcard);
|
||||
await SubscribeRpcAsync<object, BotStatusDto>(MqttTopics.RequestFilter(MqttTopics.Channels.BotGetStatus), HandleGetStatusRpcAsync);
|
||||
await SubscribeRpcAsync<object, List<BotTradeOrderDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.BotGetPositions), HandleGetPositionsRpcAsync);
|
||||
await SubscribeRpcAsync<object, AccountSummaryDto>(MqttTopics.RequestFilter(MqttTopics.Channels.BotGetSummary), HandleGetSummaryRpcAsync);
|
||||
await SubscribeRpcAsync<ExecuteProposalRequest, BotTradeOrderDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.BotExecuteProposal), HandleExecuteProposalRpcAsync);
|
||||
await SubscribeRpcAsync<object, PanicCloseResultDto>(MqttTopics.RequestFilter(MqttTopics.Channels.BotPanicClose), HandlePanicCloseRpcAsync);
|
||||
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.BotSettingsGetAll), HandleSettingsGetAllRpcAsync);
|
||||
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.BotSettingsUpdate), HandleSettingsUpdateRpcAsync);
|
||||
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync);
|
||||
|
||||
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
||||
// Subscribe to Engine Proposals
|
||||
await SubscribeAsync(MqttTopics.EngineProposalsCreated);
|
||||
|
||||
FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
||||
{
|
||||
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticBot", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await PublishAsync("finlytic/logs/FinlyticBot", logDto);
|
||||
await PublishAsync(MqttTopics.Logs("FinlyticBot"), logDto);
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogInformation("Successfully subscribed to FinlyticBot event and RPC channels.");
|
||||
await _finlyticLogger.LogInfoAsync(BotSettingKeys.MqttChannel, "[BotMqttClient] FinlyticBot MQTT client connected. Registering RPC endpoints...");
|
||||
}
|
||||
|
||||
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase))
|
||||
if (topic.Equals(MqttTopics.EngineProposalsCreated, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var segments = topic.Split('/');
|
||||
bool isForMe = segments.Length >= 5
|
||||
? segments[3].Equals("FinlyticBot", StringComparison.OrdinalIgnoreCase)
|
||||
: topic.Contains("FinlyticBot", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isForMe)
|
||||
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr, DefaultJsonOptions);
|
||||
if (proposal != null && EngineProposalConsumerBackgroundService.OnProposalReceived != null)
|
||||
{
|
||||
string correlationId = segments[^1];
|
||||
string respTopic = $"services/response/health_Ping/{correlationId}";
|
||||
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticBot", "Online", DateTime.UtcNow, "Connected"));
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<BotMqttClient>>();
|
||||
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel,
|
||||
"[FinlyticBot] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId);
|
||||
EngineProposalConsumerBackgroundService.OnProposalReceived(proposal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic.StartsWith("services/request/bot_Settings_GetAll/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var correlationId = topic.Split('/')[^1];
|
||||
await HandleSettingsGetAllAsync(correlationId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic.StartsWith("services/request/bot_Settings_Update/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var correlationId = topic.Split('/')[^1];
|
||||
await HandleSettingsUpdateAsync(correlationId, payloadStr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic.Equals("services/events/trades/proposal", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("finlytic/trades/proposed/", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.Equals("services/events/analyzer/trade_proposed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleTradeProposalEventAsync(payloadStr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing incoming MQTT message on topic {Topic}", topic);
|
||||
_logger.LogError(ex, "[BotMqttClient] Error handling incoming proposal on topic {Topic}", topic);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTradeProposalEventAsync(string payloadStr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadStr)) return;
|
||||
|
||||
TradeProposalDto? proposal = null;
|
||||
try
|
||||
{
|
||||
proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr, FinlyticJsonSerializerContext.Default.TradeProposalDto);
|
||||
}
|
||||
catch
|
||||
{
|
||||
proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr);
|
||||
}
|
||||
|
||||
if (proposal == null) return;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var executionService = scope.ServiceProvider.GetRequiredService<IBotOrderExecutionService>();
|
||||
await executionService.ProcessTradeProposalAsync(proposal);
|
||||
}
|
||||
|
||||
private async Task HandleSettingsGetAllAsync(string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||
|
||||
var responseTopic = $"services/response/bot_Settings_GetAll/{correlationId}";
|
||||
await PublishAsync(responseTopic, settings);
|
||||
}
|
||||
|
||||
private async Task HandleSettingsUpdateAsync(string correlationId, string payload)
|
||||
private async Task<BotStatusDto> HandleGetStatusRpcAsync(object? _, string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
var alpacaService = scope.ServiceProvider.GetRequiredService<IAlpacaTradingService>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
|
||||
Dictionary<string, object?>? updates = null;
|
||||
int active = await db.Positions.CountAsync(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered);
|
||||
bool autoExec = await settingsService.GetSettingAsync(BotSettingKeys.EnableAutoExecution);
|
||||
int maxPositions = await settingsService.GetSettingAsync(BotSettingKeys.MaxConcurrentPositions);
|
||||
decimal riskPerTradePercent = await settingsService.GetSettingAsync(BotSettingKeys.RiskPerTradePercent);
|
||||
|
||||
// MinCompositeScore is owned by FinlyticEngine (EngineSettingKeys.MinCompositeScore ==
|
||||
// "Engine.MinCompositeScore", read as `decimal` there — see TradeLifecycleService). FinlyticBot has no
|
||||
// project reference to FinlyticEngine (and is out of scope for adding one here), but both services share
|
||||
// the same dynamic settings store, so the raw string key is read directly instead of duplicating/inventing
|
||||
// a Bot-local setting. Default mirrors EngineSettingKeys' own documented default.
|
||||
decimal minCompositeScoreRaw = await settingsService.GetSettingAsync("Engine.MinCompositeScore", 75.0m);
|
||||
|
||||
// The bot worker process itself is always running once started; "IsRunning" from the client's
|
||||
// perspective means "is the bot actively acting on proposals", which is exactly what
|
||||
// EngineProposalConsumerBackgroundService gates on before calling IBotOrderExecutor. So IsRunning
|
||||
// is deliberately the same flag as AutoExecutionEnabled rather than a separate process-alive flag.
|
||||
bool isRunning = autoExec;
|
||||
|
||||
// Synthetic broker is always available (in-process ledger); Alpaca is only listed once real
|
||||
// credentials are configured (IAlpacaTradingService.IsConfigured), mirroring the venue selection
|
||||
// logic in BotOrderExecutor.
|
||||
string venuesActive = alpacaService.IsConfigured
|
||||
? $"{BotExecutionVenue.SyntheticPaperBroker}, {BotExecutionVenue.AlpacaPaperTrading}"
|
||||
: BotExecutionVenue.SyntheticPaperBroker.ToString();
|
||||
|
||||
return new BotStatusDto(
|
||||
IsRunning: isRunning,
|
||||
AutoExecutionEnabled: autoExec,
|
||||
ActivePositionsCount: active,
|
||||
MaxPositions: maxPositions,
|
||||
RiskPerTradePercent: riskPerTradePercent,
|
||||
MinCompositeScore: (int)Math.Round(minCompositeScoreRaw, MidpointRounding.AwayFromZero),
|
||||
VenuesActive: venuesActive
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<List<BotTradeOrderDto>> HandleGetPositionsRpcAsync(object? _, string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
var positions = await db.Positions
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit)
|
||||
.OrderByDescending(p => p.OpenedAtUtc)
|
||||
.ToListAsync();
|
||||
|
||||
return positions.Select(BotOrderExecutor.MapEntityToDto).ToList();
|
||||
}
|
||||
|
||||
private async Task<AccountSummaryDto> HandleGetSummaryRpcAsync(object? _, string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var syntheticBroker = scope.ServiceProvider.GetRequiredService<ISyntheticPaperBroker>();
|
||||
return await syntheticBroker.GetSummaryAsync();
|
||||
}
|
||||
|
||||
private async Task<BotTradeOrderDto?> HandleExecuteProposalRpcAsync(ExecuteProposalRequest? req, string correlationId)
|
||||
{
|
||||
if (req == null) return null;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IBotOrderExecutor>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<BotMqttClient>>();
|
||||
|
||||
// Look up the existing proposal by its actual GUID via the engine_GetProposals RPC channel — the
|
||||
// same channel EngineController/UserTradesController already use for proposal listings.
|
||||
// (req.ProposalId is a proposal GUID, not an ISIN; a previous version of this method sent it to
|
||||
// engine_EvaluateIsin as if it were one, which started a full re-evaluation of a nonsensical "ISIN"
|
||||
// and could never resolve a proposal — see Bug A of the security review.)
|
||||
// A generous limit is used because the proposal the caller wants to execute may not be among the
|
||||
// most recently created handful if several proposals were generated in quick succession.
|
||||
List<TradeProposalDto>? proposals;
|
||||
try
|
||||
{
|
||||
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
|
||||
proposals = await SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||
MqttTopics.Channels.EngineGetProposals,
|
||||
new GetTradeProposalsRequest(OnlyActive: true, Limit: 200),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
|
||||
if (list != null)
|
||||
await logger.LogWarningAsync(BotSettingKeys.BotChannel, ex,
|
||||
"[BotMqttClient] RPC failure while fetching proposals from FinlyticEngine to resolve proposal {ProposalId} for execution [CorrelationId: {CorrelationId}]",
|
||||
req.ProposalId, correlationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (proposals == null)
|
||||
{
|
||||
await logger.LogWarningAsync(BotSettingKeys.BotChannel,
|
||||
"[BotMqttClient] FinlyticEngine did not respond to engine_GetProposals in time while resolving proposal {ProposalId} for execution [CorrelationId: {CorrelationId}]",
|
||||
req.ProposalId, correlationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var proposal = proposals.FirstOrDefault(p => p.ProposalId == req.ProposalId);
|
||||
if (proposal == null)
|
||||
{
|
||||
await logger.LogWarningAsync(BotSettingKeys.BotChannel,
|
||||
"[BotMqttClient] Proposal {ProposalId} was not found among FinlyticEngine's active proposals (expired or invalid) [CorrelationId: {CorrelationId}]",
|
||||
req.ProposalId, correlationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await logger.LogInfoAsync(BotSettingKeys.BotChannel,
|
||||
"[BotMqttClient] Executing manual paper trade for proposal {Id} [CorrelationId: {CorrelationId}]", req.ProposalId, correlationId);
|
||||
return await executor.ExecuteProposalAsync(proposal, req.PreferredVenue, req.CustomQuantity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emergency-closes every currently open paper-trading position (Status in Active, BreakEvenTriggered,
|
||||
/// Tp1Hit, Tp2Hit — the terminal statuses Closed/StoppedOut/KnockedOut/Canceled are, by definition,
|
||||
/// already not open and Pending is not currently assigned by any code path).
|
||||
/// <para>
|
||||
/// Synthetic ledger positions are closed unconditionally via <see cref="ISyntheticPaperBroker"/> — there
|
||||
/// is no external broker to confirm with, so the internal ledger IS the authority.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Alpaca positions are the safety-critical case: this handler NEVER marks an Alpaca position as closed
|
||||
/// unless <see cref="IAlpacaTradingService.ClosePositionAsync"/> returns successfully (i.e. Alpaca's REST
|
||||
/// API confirmed it accepted the liquidation order). If Alpaca is not configured, or the broker call
|
||||
/// throws, the position is left completely untouched in the database and is counted in
|
||||
/// <see cref="PanicCloseResultDto.SkippedCount"/> rather than silently reported as closed
|
||||
/// (Rules.md §4 — a false-positive "closed" on a real broker position would be fatal).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<PanicCloseResultDto> HandlePanicCloseRpcAsync(object? _, string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
var syntheticBroker = scope.ServiceProvider.GetRequiredService<ISyntheticPaperBroker>();
|
||||
var alpacaService = scope.ServiceProvider.GetRequiredService<IAlpacaTradingService>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<BotMqttClient>>();
|
||||
|
||||
var openStatuses = new[]
|
||||
{
|
||||
BotPositionStatus.Active,
|
||||
BotPositionStatus.BreakEvenTriggered,
|
||||
BotPositionStatus.Tp1Hit,
|
||||
BotPositionStatus.Tp2Hit
|
||||
};
|
||||
|
||||
var positions = await db.Positions
|
||||
.Where(p => openStatuses.Contains(p.Status))
|
||||
.OrderBy(p => p.OpenedAtUtc)
|
||||
.ToListAsync();
|
||||
|
||||
await logger.LogWarningAsync(BotSettingKeys.BotChannel,
|
||||
"[BotMqttClient] PANIC CLOSE triggered: attempting to close {Count} open position(s) [CorrelationId: {CorrelationId}]",
|
||||
positions.Count, correlationId);
|
||||
|
||||
var closedOrders = new List<BotTradeOrderDto>();
|
||||
int skippedCount = 0;
|
||||
|
||||
foreach (var pos in positions)
|
||||
{
|
||||
if (pos.Venue == BotExecutionVenue.SyntheticPaperBroker)
|
||||
{
|
||||
updates = new Dictionary<string, object?>();
|
||||
foreach (var item in list) updates[item.Key] = item.Value;
|
||||
// No external broker to confirm with — the internal ledger is the authority for its own
|
||||
// positions, so this closes unconditionally at the last synced price (same direction-aware
|
||||
// realized P&L formula ISyntheticPaperBroker already uses elsewhere for consistency).
|
||||
var closed = await syntheticBroker.ClosePositionAsync(pos.Id, pos.CurrentPrice, BotPositionStatus.Closed);
|
||||
closedOrders.Add(BotOrderExecutor.MapEntityToDto(closed));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alpaca venue: only ever mark closed after the broker confirms the liquidation.
|
||||
if (!alpacaService.IsConfigured)
|
||||
{
|
||||
await logger.LogWarningAsync(BotSettingKeys.BotChannel,
|
||||
"[BotMqttClient] PANIC CLOSE SKIPPED Alpaca position {PositionId} ({Symbol}): Alpaca is not configured. Position left OPEN in the ledger — manual intervention required.",
|
||||
pos.Id, pos.Symbol);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var closeResult = await alpacaService.ClosePositionAsync(pos.Symbol);
|
||||
|
||||
// Alpaca confirmed acceptance of the liquidation order — only now is it safe to persist
|
||||
// the position as closed. AverageFillPrice can still be null immediately after submission
|
||||
// (e.g. outside market hours); fall back to the last synced price rather than fabricating one.
|
||||
decimal exitPrice = closeResult.AverageFillPrice ?? pos.CurrentPrice;
|
||||
pos.Status = BotPositionStatus.Closed;
|
||||
pos.ClosedAtUtc = DateTime.UtcNow;
|
||||
pos.CurrentPrice = exitPrice;
|
||||
pos.LastSyncAtUtc = DateTime.UtcNow;
|
||||
pos.RealizedPnlEur = CalculateRealizedPnl(pos, exitPrice);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await logger.LogInfoAsync(BotSettingKeys.BotChannel,
|
||||
"[BotMqttClient] PANIC CLOSE confirmed by Alpaca for position {PositionId} ({Symbol}): Order {OrderId} (Status: {Status}), exit {Exit:F2} €.",
|
||||
pos.Id, pos.Symbol, closeResult.OrderId, closeResult.OrderStatus, exitPrice);
|
||||
|
||||
closedOrders.Add(BotOrderExecutor.MapEntityToDto(pos));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The broker call itself failed (not configured mid-flight, network/HTTP error, or Alpaca
|
||||
// rejected the request) — the position is left completely untouched, exactly as it was
|
||||
// before this handler ran. It must NOT be counted as closed.
|
||||
await logger.LogWarningAsync(BotSettingKeys.BotChannel, ex,
|
||||
"[BotMqttClient] PANIC CLOSE FAILED for Alpaca position {PositionId} ({Symbol}): broker call did not confirm liquidation. Position left OPEN in the ledger — manual intervention required.",
|
||||
pos.Id, pos.Symbol);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return new PanicCloseResultDto(closedOrders.Count, skippedCount, closedOrders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction-aware realized P&L for a position closed at <paramref name="exitPrice"/>, mirroring the
|
||||
/// formula <see cref="ISyntheticPaperBroker"/> already uses for its own (non-knock-out) closes, so both
|
||||
/// venues report P&L consistently. Only used for the Alpaca panic-close path here — the synthetic path
|
||||
/// delegates to <see cref="ISyntheticPaperBroker.ClosePositionAsync"/> directly, which computes its own.
|
||||
/// </summary>
|
||||
private static decimal CalculateRealizedPnl(BotPositionEntity pos, decimal exitPrice)
|
||||
{
|
||||
decimal pnl = pos.Direction == SignalDirection.Buy
|
||||
? ((exitPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
|
||||
: ((pos.AverageBuyIn - exitPrice) * pos.Quantity) - pos.TotalFeesEur;
|
||||
|
||||
return Math.Round(pnl, 2);
|
||||
}
|
||||
|
||||
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BotSettingKeys) });
|
||||
}
|
||||
|
||||
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
|
||||
if (updates != null && updates.Count > 0)
|
||||
{
|
||||
await settingsService.UpdateSettingsAsync(updates);
|
||||
}
|
||||
|
||||
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||
var responseTopic = $"services/response/bot_Settings_Update/{correlationId}";
|
||||
await PublishAsync(responseTopic, currentSettings);
|
||||
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BotSettingKeys) });
|
||||
}
|
||||
|
||||
private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId)
|
||||
{
|
||||
if (topic.Contains("FinlyticBot", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
|
||||
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticBot", "Online", DateTime.UtcNow, "Connected"));
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<BotMqttClient>>();
|
||||
await logger.LogInfoAsync(BotSettingKeys.HealthPingChannel,
|
||||
"[FinlyticBot] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using FinlyticCore.Models.Settings;
|
||||
|
||||
namespace FinlyticBot.Util;
|
||||
|
||||
public static class SettingKeys
|
||||
{
|
||||
// --- Logging-Kanäle ---
|
||||
public static readonly SettingKey<bool> BotChannel = new("Logging.Channel.Bot", true);
|
||||
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||
|
||||
// --- Master Bot Control ---
|
||||
public static readonly SettingKey<bool> IsEnabled = new("Bot.IsEnabled", false);
|
||||
|
||||
// --- Filter & Zulassungskriterien ---
|
||||
public static readonly SettingKey<double> MinCrv = new("Bot.MinCrv", 1.50);
|
||||
public static readonly SettingKey<double> MinWinRate = new("Bot.MinWinRate", 65.0);
|
||||
public static readonly SettingKey<double> MaxVixThreshold = new("Bot.MaxVixThreshold", 25.0);
|
||||
public static readonly SettingKey<double> MaxEntryDeviationPercent = new("Bot.MaxEntryDeviationPercent", 0.75);
|
||||
|
||||
// --- Risiko-Management & Positionsgrößen ---
|
||||
public static readonly SettingKey<double> RiskPerTradePercent = new("Bot.RiskPerTradePercent", 1.0);
|
||||
public static readonly SettingKey<double> MaxSinglePositionCap = new("Bot.MaxSinglePositionCap", 5000.0);
|
||||
public static readonly SettingKey<int> MaxOpenTrades = new("Bot.MaxOpenTrades", 5);
|
||||
public static readonly SettingKey<double> DailyLossLimitPercent = new("Bot.DailyLossLimitPercent", 3.0);
|
||||
public static readonly SettingKey<int> MaxConsecutiveLosses = new("Bot.MaxConsecutiveLosses", 3);
|
||||
|
||||
// --- Order Execution Strategie ---
|
||||
public static readonly SettingKey<string> TakeProfitMode = new("Bot.TakeProfitMode", "Split50_50"); // "TP1_Only", "TP2_Only", "Split50_50"
|
||||
public static readonly SettingKey<string> ExecutionOrderType = new("Bot.ExecutionOrderType", "Limit"); // "Limit", "Market"
|
||||
|
||||
// --- Alpaca API Konfiguration (Optional live im UI überschreibbar) ---
|
||||
public static readonly SettingKey<string> AlpacaKeyId = new("Alpaca.KeyId", "");
|
||||
public static readonly SettingKey<string> AlpacaSecretKey = new("Alpaca.SecretKey", "");
|
||||
public static readonly SettingKey<bool> AlpacaIsPaper = new("Alpaca.IsPaper", true);
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=OmniDB;Database=finlytic_bot;Username=admin;Password=YourPasswordHere"
|
||||
"DefaultConnection": "Host=localhost;Database=finlytic_bot;Username=postgres;Password=postgres"
|
||||
},
|
||||
"MQTT": {
|
||||
"Host": "localhost",
|
||||
@@ -14,8 +15,8 @@
|
||||
"ClientId": "finlytic_bot"
|
||||
},
|
||||
"Alpaca": {
|
||||
"KeyId": "",
|
||||
"SecretKey": "",
|
||||
"KeyId": "PK_PAPER_PLACEHOLDER_KEY",
|
||||
"SecretKey": "SK_PAPER_PLACEHOLDER_SECRET",
|
||||
"IsPaper": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user