Implement A1, A6, A2: Add TraderPosition entity, PositionPnLEngine (Average-Cost-Method), Market-level WinRate, and integrate with TraderAnalyticsWorker
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IPositionPnLEngine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recalculates all positions, average costs, and PnL values for a trader by re-playing their trade history.
|
||||||
|
/// Updates the TraderPosition records in the database, calculates realized/unrealized PnL,
|
||||||
|
/// and updates the Trader's TotalPnl and WinRate.
|
||||||
|
/// </summary>
|
||||||
|
Task RecalculateTraderPositionsAsync(int traderId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -66,4 +66,5 @@ public class Trader
|
|||||||
public TraderScore? CurrentScore { get; set; }
|
public TraderScore? CurrentScore { get; set; }
|
||||||
public virtual TraderAnalytics? Analytics { get; set; }
|
public virtual TraderAnalytics? Analytics { get; set; }
|
||||||
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
|
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
|
||||||
|
public ICollection<TraderPosition> Positions { get; set; } = new List<TraderPosition>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a trader's position in a specific market outcome.
|
||||||
|
/// Tracks shares held, average purchase price, and realized profit/loss.
|
||||||
|
/// </summary>
|
||||||
|
public class TraderPosition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the trader.</summary>
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the resolved market outcome.</summary>
|
||||||
|
public int MarketOutcomeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of shares currently held.</summary>
|
||||||
|
public decimal SharesHeld { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Average purchase cost per share.</summary>
|
||||||
|
public decimal AvgCost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Realized profit/loss from closed portions of this position.</summary>
|
||||||
|
public decimal RealizedPnl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this position was last updated.</summary>
|
||||||
|
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public Trader Trader { get; set; } = null!;
|
||||||
|
public MarketOutcome MarketOutcome { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
|
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
|
||||||
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
|
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
|
||||||
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
|
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
|
||||||
|
public DbSet<TraderPosition> TraderPositions => Set<TraderPosition>();
|
||||||
|
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
@@ -153,5 +154,17 @@ public class AppDbContext : DbContext
|
|||||||
e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
|
e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
|
||||||
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
|
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TraderPosition
|
||||||
|
mb.Entity<TraderPosition>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(tp => tp.Id);
|
||||||
|
e.HasIndex(tp => new { tp.TraderId, tp.MarketOutcomeId }).IsUnique();
|
||||||
|
e.Property(tp => tp.SharesHeld).HasPrecision(14, 6);
|
||||||
|
e.Property(tp => tp.AvgCost).HasPrecision(10, 6);
|
||||||
|
e.Property(tp => tp.RealizedPnl).HasPrecision(18, 4);
|
||||||
|
e.HasOne(tp => tp.Trader).WithMany(t => t.Positions).HasForeignKey(tp => tp.TraderId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Predictalytics.Application.Interfaces;
|
using Predictalytics.Application.Interfaces;
|
||||||
using Predictalytics.Application.Services;
|
using Predictalytics.Application.Services;
|
||||||
|
using Predictalytics.Infrastructure.Services;
|
||||||
using Predictalytics.Domain.Interfaces;
|
using Predictalytics.Domain.Interfaces;
|
||||||
using Predictalytics.Infrastructure.Data;
|
using Predictalytics.Infrastructure.Data;
|
||||||
using Predictalytics.Infrastructure.Data.Repositories;
|
using Predictalytics.Infrastructure.Data.Repositories;
|
||||||
@@ -63,6 +64,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAlertRepository, AlertRepository>();
|
services.AddScoped<IAlertRepository, AlertRepository>();
|
||||||
|
|
||||||
// Application Services
|
// Application Services
|
||||||
|
services.AddScoped<IPositionPnLEngine, PositionPnLEngine>();
|
||||||
services.AddScoped<IScoringService, ScoringService>();
|
services.AddScoped<IScoringService, ScoringService>();
|
||||||
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
||||||
services.AddScoped<IAlertService, AlertService>();
|
services.AddScoped<IAlertService, AlertService>();
|
||||||
|
|||||||
+701
@@ -0,0 +1,701 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260703091631_AddTraderPosition")]
|
||||||
|
partial class AddTraderPosition
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.11")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Severity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int?>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId");
|
||||||
|
|
||||||
|
b.ToTable("Alerts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DbCreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("EventSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ImageUrl")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsResolved")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTradesUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Liquidity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformMarketId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Question")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("ResolutionOutcome")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Volume")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformMarketId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Markets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("MarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageTradeSize")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("BotActivityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("UniqueTradersCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("MarketId");
|
||||||
|
|
||||||
|
b.ToTable("MarketAnalytics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasPrecision(18, 8)
|
||||||
|
.HasColumnType("decimal(18,8)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenId");
|
||||||
|
|
||||||
|
b.HasIndex("MarketId", "OutcomeIndex")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MarketOutcomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SettingsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PlatformConfigs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("AssetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("varchar(80)");
|
||||||
|
|
||||||
|
b.Property<int?>("DbMarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(66)
|
||||||
|
.HasColumnType("varchar(66)");
|
||||||
|
|
||||||
|
b.Property<int?>("MarketOutcomeId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Outcome")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformTradeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasPrecision(10, 6)
|
||||||
|
.HasColumnType("decimal(10,6)");
|
||||||
|
|
||||||
|
b.Property<int>("Side")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Size")
|
||||||
|
.HasPrecision(14, 6)
|
||||||
|
.HasColumnType("decimal(14,6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionHash")
|
||||||
|
.HasMaxLength(66)
|
||||||
|
.HasColumnType("varchar(66)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("DbMarketId");
|
||||||
|
|
||||||
|
b.HasIndex("ExecutedAt");
|
||||||
|
|
||||||
|
b.HasIndex("MarketOutcomeId");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformTradeId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Trades");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAutoDiscovered")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsInitialImportComplete")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuspectedBot")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastApiErrorAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastPolledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTradesUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ManualPriorityOverride")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Strategy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Tier")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalPnl")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("TotalTrades")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Traders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OverallPnL")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OverallWinRate")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL24h")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL30d")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL7d")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate24h")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate30d")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate7d")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("TraderId");
|
||||||
|
|
||||||
|
b.ToTable("TraderAnalytics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("AvgCost")
|
||||||
|
.HasPrecision(10, 6)
|
||||||
|
.HasColumnType("decimal(10,6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("MarketOutcomeId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnl")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SharesHeld")
|
||||||
|
.HasPrecision(14, 6)
|
||||||
|
.HasColumnType("decimal(14,6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MarketOutcomeId");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId", "MarketOutcomeId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TraderPositions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("ActivityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CombinedScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TimingScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("VolumeScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TraderScores");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("AlertsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("WatchlistEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
|
||||||
|
.WithOne("Analytics")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Market");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
|
||||||
|
.WithMany("Outcomes")
|
||||||
|
.HasForeignKey("MarketId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Market");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DbMarketId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MarketOutcomeId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("Trades")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("DbMarket");
|
||||||
|
|
||||||
|
b.Navigation("MarketOutcome");
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithOne("Analytics")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MarketOutcomeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("Positions")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("MarketOutcome");
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithOne("CurrentScore")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("WatchlistEntries")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Analytics");
|
||||||
|
|
||||||
|
b.Navigation("Outcomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Analytics");
|
||||||
|
|
||||||
|
b.Navigation("CurrentScore");
|
||||||
|
|
||||||
|
b.Navigation("Positions");
|
||||||
|
|
||||||
|
b.Navigation("Trades");
|
||||||
|
|
||||||
|
b.Navigation("WatchlistEntries");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTraderPosition : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TraderPositions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
MarketOutcomeId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SharesHeld = table.Column<decimal>(type: "decimal(14,6)", precision: 14, scale: 6, nullable: false),
|
||||||
|
AvgCost = table.Column<decimal>(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false),
|
||||||
|
RealizedPnl = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
LastUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TraderPositions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TraderPositions_MarketOutcomes_MarketOutcomeId",
|
||||||
|
column: x => x.MarketOutcomeId,
|
||||||
|
principalTable: "MarketOutcomes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TraderPositions_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TraderPositions_MarketOutcomeId",
|
||||||
|
table: "TraderPositions",
|
||||||
|
column: "MarketOutcomeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TraderPositions_TraderId_MarketOutcomeId",
|
||||||
|
table: "TraderPositions",
|
||||||
|
columns: new[] { "TraderId", "MarketOutcomeId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TraderPositions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -447,6 +447,45 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.ToTable("TraderAnalytics");
|
b.ToTable("TraderAnalytics");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("AvgCost")
|
||||||
|
.HasPrecision(10, 6)
|
||||||
|
.HasColumnType("decimal(10,6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("MarketOutcomeId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnl")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SharesHeld")
|
||||||
|
.HasPrecision(14, 6)
|
||||||
|
.HasColumnType("decimal(14,6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MarketOutcomeId");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId", "MarketOutcomeId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TraderPositions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -593,6 +632,25 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.Navigation("Trader");
|
b.Navigation("Trader");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MarketOutcomeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("Positions")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("MarketOutcome");
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
@@ -628,6 +686,8 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CurrentScore");
|
b.Navigation("CurrentScore");
|
||||||
|
|
||||||
|
b.Navigation("Positions");
|
||||||
|
|
||||||
b.Navigation("Trades");
|
b.Navigation("Trades");
|
||||||
|
|
||||||
b.Navigation("WatchlistEntries");
|
b.Navigation("WatchlistEntries");
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Services;
|
||||||
|
|
||||||
|
public class PositionPnLEngine : IPositionPnLEngine
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<PositionPnLEngine> _logger;
|
||||||
|
|
||||||
|
public PositionPnLEngine(AppDbContext db, ILogger<PositionPnLEngine> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecalculateTraderPositionsAsync(int traderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var trader = await _db.Traders
|
||||||
|
.Include(t => t.Analytics)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == traderId, ct);
|
||||||
|
|
||||||
|
if (trader == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("RecalculateTraderPositions: Trader {TraderId} not found.", traderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all trades for this trader, sorted chronologically, including outcomes and markets
|
||||||
|
var trades = await _db.Trades
|
||||||
|
.Include(t => t.MarketOutcome)
|
||||||
|
.ThenInclude(o => o!.Market)
|
||||||
|
.Where(t => t.TraderId == traderId)
|
||||||
|
.OrderBy(t => t.ExecutedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Fetch existing positions for this trader to update or replace them
|
||||||
|
var existingPositions = await _db.TraderPositions
|
||||||
|
.Where(tp => tp.TraderId == traderId)
|
||||||
|
.ToDictionaryAsync(tp => tp.MarketOutcomeId, ct);
|
||||||
|
|
||||||
|
var tempPositions = new Dictionary<int, TraderPosition>();
|
||||||
|
|
||||||
|
var cutoff30d = DateTime.UtcNow.AddDays(-30);
|
||||||
|
var cutoff7d = DateTime.UtcNow.AddDays(-7);
|
||||||
|
var cutoff24h = DateTime.UtcNow.AddHours(-24);
|
||||||
|
|
||||||
|
var realizedPnl30d = 0m;
|
||||||
|
var realizedPnl7d = 0m;
|
||||||
|
var realizedPnl24h = 0m;
|
||||||
|
|
||||||
|
// Tracks outcomes traded within time frames
|
||||||
|
var tradedOutcomes30d = new HashSet<int>();
|
||||||
|
var tradedOutcomes7d = new HashSet<int>();
|
||||||
|
var tradedOutcomes24h = new HashSet<int>();
|
||||||
|
|
||||||
|
foreach (var trade in trades)
|
||||||
|
{
|
||||||
|
if (trade.MarketOutcomeId == null || trade.MarketOutcome == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var outcomeId = trade.MarketOutcomeId.Value;
|
||||||
|
|
||||||
|
// Track if trade is within windows
|
||||||
|
if (trade.ExecutedAt >= cutoff30d) tradedOutcomes30d.Add(outcomeId);
|
||||||
|
if (trade.ExecutedAt >= cutoff7d) tradedOutcomes7d.Add(outcomeId);
|
||||||
|
if (trade.ExecutedAt >= cutoff24h) tradedOutcomes24h.Add(outcomeId);
|
||||||
|
|
||||||
|
if (!tempPositions.TryGetValue(outcomeId, out var pos))
|
||||||
|
{
|
||||||
|
if (existingPositions.TryGetValue(outcomeId, out var existing))
|
||||||
|
{
|
||||||
|
pos = existing;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pos = new TraderPosition
|
||||||
|
{
|
||||||
|
TraderId = traderId,
|
||||||
|
MarketOutcomeId = outcomeId,
|
||||||
|
SharesHeld = 0,
|
||||||
|
AvgCost = 0,
|
||||||
|
RealizedPnl = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pos.LastUpdatedAt = DateTime.UtcNow;
|
||||||
|
tempPositions[outcomeId] = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousRealizedPnl = pos.RealizedPnl;
|
||||||
|
|
||||||
|
// Apply trade side booking rules
|
||||||
|
switch (trade.Side)
|
||||||
|
{
|
||||||
|
case TradeSide.Buy:
|
||||||
|
if (pos.SharesHeld == 0)
|
||||||
|
{
|
||||||
|
pos.AvgCost = trade.Price;
|
||||||
|
pos.SharesHeld = trade.Size;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Weighted average cost
|
||||||
|
var totalCost = (pos.SharesHeld * pos.AvgCost) + (trade.Size * trade.Price);
|
||||||
|
var totalShares = pos.SharesHeld + trade.Size;
|
||||||
|
pos.AvgCost = totalShares > 0 ? totalCost / totalShares : 0;
|
||||||
|
pos.SharesHeld = totalShares;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TradeSide.Sell:
|
||||||
|
var sizeToSell = Math.Min(trade.Size, pos.SharesHeld);
|
||||||
|
pos.RealizedPnl += sizeToSell * (trade.Price - pos.AvgCost);
|
||||||
|
pos.SharesHeld -= trade.Size;
|
||||||
|
if (pos.SharesHeld < 0)
|
||||||
|
{
|
||||||
|
pos.SharesHeld = 0; // clamp to 0
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TradeSide.Redeem:
|
||||||
|
var market = trade.MarketOutcome.Market;
|
||||||
|
var isResolved = market?.IsResolved ?? false;
|
||||||
|
var resolutionOutcome = market?.ResolutionOutcome;
|
||||||
|
var isWinner = isResolved && IsWinningOutcome(trade.MarketOutcome, resolutionOutcome);
|
||||||
|
|
||||||
|
var payout = isWinner ? 1.00m : 0.00m;
|
||||||
|
pos.RealizedPnl += pos.SharesHeld * (payout - pos.AvgCost);
|
||||||
|
pos.SharesHeld = 0;
|
||||||
|
pos.AvgCost = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TradeSide.Split:
|
||||||
|
case TradeSide.Merge:
|
||||||
|
case TradeSide.AddLiquidity:
|
||||||
|
case TradeSide.RemoveLiquidity:
|
||||||
|
case TradeSide.Unknown:
|
||||||
|
default:
|
||||||
|
// Ignored for PnL
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl;
|
||||||
|
if (realizedPnlDelta != 0)
|
||||||
|
{
|
||||||
|
if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta;
|
||||||
|
if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta;
|
||||||
|
if (trade.ExecutedAt >= cutoff24h) realizedPnl24h += realizedPnlDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist new / updated positions and calculate total values
|
||||||
|
decimal totalRealizedPnl = 0;
|
||||||
|
decimal totalUnrealizedPnl = 0;
|
||||||
|
decimal unrealizedPnl30d = 0;
|
||||||
|
decimal unrealizedPnl7d = 0;
|
||||||
|
decimal unrealizedPnl24h = 0;
|
||||||
|
|
||||||
|
foreach (var pos in tempPositions.Values)
|
||||||
|
{
|
||||||
|
var outcome = trades.FirstOrDefault(t => t.MarketOutcomeId == pos.MarketOutcomeId)?.MarketOutcome;
|
||||||
|
if (pos.SharesHeld > 0 && outcome != null)
|
||||||
|
{
|
||||||
|
var unrealized = pos.SharesHeld * (outcome.CurrentPrice - pos.AvgCost);
|
||||||
|
totalUnrealizedPnl += unrealized;
|
||||||
|
|
||||||
|
if (tradedOutcomes30d.Contains(pos.MarketOutcomeId)) unrealizedPnl30d += unrealized;
|
||||||
|
if (tradedOutcomes7d.Contains(pos.MarketOutcomeId)) unrealizedPnl7d += unrealized;
|
||||||
|
if (tradedOutcomes24h.Contains(pos.MarketOutcomeId)) unrealizedPnl24h += unrealized;
|
||||||
|
}
|
||||||
|
totalRealizedPnl += pos.RealizedPnl;
|
||||||
|
|
||||||
|
if (pos.Id == 0)
|
||||||
|
{
|
||||||
|
_db.TraderPositions.Add(pos);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_db.TraderPositions.Update(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove positions for outcomes that have no trades anymore
|
||||||
|
foreach (var outcomeId in existingPositions.Keys)
|
||||||
|
{
|
||||||
|
if (!tempPositions.ContainsKey(outcomeId))
|
||||||
|
{
|
||||||
|
_db.TraderPositions.Remove(existingPositions[outcomeId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update analytics record
|
||||||
|
var analytics = trader.Analytics;
|
||||||
|
if (analytics == null)
|
||||||
|
{
|
||||||
|
analytics = new TraderAnalytics { TraderId = traderId };
|
||||||
|
_db.TraderAnalytics.Add(analytics);
|
||||||
|
}
|
||||||
|
|
||||||
|
var overallPnl = totalRealizedPnl + totalUnrealizedPnl;
|
||||||
|
analytics.OverallPnL = overallPnl;
|
||||||
|
analytics.PnL30d = realizedPnl30d + unrealizedPnl30d;
|
||||||
|
analytics.PnL7d = realizedPnl7d + unrealizedPnl7d;
|
||||||
|
analytics.PnL24h = realizedPnl24h + unrealizedPnl24h;
|
||||||
|
|
||||||
|
// Calculate Win Rate on Market level
|
||||||
|
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
|
||||||
|
|
||||||
|
analytics.OverallWinRate = winRateOverall;
|
||||||
|
analytics.WinRate30d = winRate30d;
|
||||||
|
analytics.WinRate7d = winRate7d;
|
||||||
|
analytics.WinRate24h = winRate24h;
|
||||||
|
analytics.LastCalculatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Sync back to Trader record for quick sorting / UI display
|
||||||
|
trader.TotalPnl = overallPnl;
|
||||||
|
trader.WinRate = winRateOverall;
|
||||||
|
|
||||||
|
// Save changes to database
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("Recalculated positions for trader {TraderName} (Id={TraderId}): RealizedPnL={Realized:F4}, UnrealizedPnL={Unrealized:F4}, Total={Total:F4}, WinRate={WinRate:F2}%",
|
||||||
|
trader.DisplayName, traderId, totalRealizedPnl, totalUnrealizedPnl, overallPnl, winRateOverall);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (decimal Overall, decimal WinRate30d, decimal WinRate7d, decimal WinRate24h) CalculateMarketWinRates(
|
||||||
|
List<Trade> trades,
|
||||||
|
Dictionary<int, TraderPosition> finalPositions,
|
||||||
|
DateTime cutoff30d,
|
||||||
|
DateTime cutoff7d,
|
||||||
|
DateTime cutoff24h)
|
||||||
|
{
|
||||||
|
// Group trades by Market
|
||||||
|
var tradesByMarket = trades
|
||||||
|
.Where(t => t.DbMarketId.HasValue || !string.IsNullOrEmpty(t.MarketId))
|
||||||
|
.GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId);
|
||||||
|
|
||||||
|
int closedMarketsOverall = 0, winsOverall = 0;
|
||||||
|
int closedMarkets30d = 0, wins30d = 0;
|
||||||
|
int closedMarkets7d = 0, wins7d = 0;
|
||||||
|
int closedMarkets24h = 0, wins24h = 0;
|
||||||
|
|
||||||
|
foreach (var marketGroup in tradesByMarket)
|
||||||
|
{
|
||||||
|
var outcomeIds = marketGroup
|
||||||
|
.Where(t => t.MarketOutcomeId.HasValue)
|
||||||
|
.Select(t => t.MarketOutcomeId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var isClosed = outcomeIds.All(oid => !finalPositions.TryGetValue(oid, out var pos) || pos.SharesHeld == 0);
|
||||||
|
if (!isClosed)
|
||||||
|
{
|
||||||
|
var firstTradeWithMarket = marketGroup.FirstOrDefault(t => t.MarketOutcome?.Market != null);
|
||||||
|
if (firstTradeWithMarket?.MarketOutcome?.Market?.IsResolved == true)
|
||||||
|
{
|
||||||
|
isClosed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClosed)
|
||||||
|
{
|
||||||
|
decimal marketPnl = 0;
|
||||||
|
foreach (var oid in outcomeIds)
|
||||||
|
{
|
||||||
|
if (finalPositions.TryGetValue(oid, out var pos))
|
||||||
|
{
|
||||||
|
marketPnl += pos.RealizedPnl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastTradeTime = marketGroup.Max(t => t.ExecutedAt);
|
||||||
|
var isWin = marketPnl > 0;
|
||||||
|
|
||||||
|
closedMarketsOverall++;
|
||||||
|
if (isWin) winsOverall++;
|
||||||
|
|
||||||
|
if (lastTradeTime >= cutoff30d)
|
||||||
|
{
|
||||||
|
closedMarkets30d++;
|
||||||
|
if (isWin) wins30d++;
|
||||||
|
}
|
||||||
|
if (lastTradeTime >= cutoff7d)
|
||||||
|
{
|
||||||
|
closedMarkets7d++;
|
||||||
|
if (isWin) wins7d++;
|
||||||
|
}
|
||||||
|
if (lastTradeTime >= cutoff24h)
|
||||||
|
{
|
||||||
|
closedMarkets24h++;
|
||||||
|
if (isWin) wins24h++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var winRateOverall = closedMarketsOverall > 0 ? (decimal)winsOverall / closedMarketsOverall * 100m : 0m;
|
||||||
|
var winRate30d = closedMarkets30d > 0 ? (decimal)wins30d / closedMarkets30d * 100m : 0m;
|
||||||
|
var winRate7d = closedMarkets7d > 0 ? (decimal)wins7d / closedMarkets7d * 100m : 0m;
|
||||||
|
var winRate24h = closedMarkets24h > 0 ? (decimal)wins24h / closedMarkets24h * 100m : 0m;
|
||||||
|
|
||||||
|
return (winRateOverall, winRate30d, winRate7d, winRate24h);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWinningOutcome(MarketOutcome outcome, string? resolutionOutcome)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(resolutionOutcome)) return false;
|
||||||
|
|
||||||
|
if (string.Equals(outcome.Label, resolutionOutcome, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (outcome.Label.EndsWith(" - " + resolutionOutcome, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Predictalytics.Infrastructure.Data;
|
using Predictalytics.Infrastructure.Data;
|
||||||
using Predictalytics.Domain.Entities;
|
using Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
namespace Predictalytics.Worker.Services;
|
namespace Predictalytics.Worker.Services;
|
||||||
|
|
||||||
@@ -12,10 +12,10 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly ILogger<TraderAnalyticsWorker> _logger;
|
private readonly ILogger<TraderAnalyticsWorker> _logger;
|
||||||
|
|
||||||
public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> logger)
|
public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> _logger)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
_logger = logger;
|
this._logger = _logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
@@ -42,6 +42,7 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
using var scope = _services.CreateScope();
|
using var scope = _services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var pnlEngine = scope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
|
||||||
|
|
||||||
var cutoff30d = DateTime.UtcNow.AddDays(-30);
|
var cutoff30d = DateTime.UtcNow.AddDays(-30);
|
||||||
|
|
||||||
@@ -56,83 +57,16 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
|
|
||||||
foreach (var id in traderIds)
|
foreach (var id in traderIds)
|
||||||
{
|
{
|
||||||
await UpdateTraderAnalyticsAsync(db, id, ct);
|
try
|
||||||
|
{
|
||||||
|
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error recalculating positions/PnL for trader {TraderId}", id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
_logger.LogInformation("Trader analytics update complete.");
|
_logger.LogInformation("Trader analytics update complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpdateTraderAnalyticsAsync(AppDbContext db, int traderId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var trades = await db.Trades.Where(t => t.TraderId == traderId).ToListAsync(ct);
|
|
||||||
if (!trades.Any()) return;
|
|
||||||
|
|
||||||
var analytics = await db.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == traderId, ct);
|
|
||||||
if (analytics == null)
|
|
||||||
{
|
|
||||||
analytics = new TraderAnalytics { TraderId = traderId };
|
|
||||||
db.TraderAnalytics.Add(analytics);
|
|
||||||
}
|
|
||||||
|
|
||||||
analytics.LastCalculatedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Simplified PnL calculation: Sum of Sells - Sum of Buys
|
|
||||||
// This is not perfect but a good starting point as requested.
|
|
||||||
// In a real scenario, we'd account for current market value of holdings.
|
|
||||||
|
|
||||||
analytics.OverallPnL = CalculatePnL(trades, null);
|
|
||||||
analytics.OverallWinRate = CalculateWinRate(trades, null);
|
|
||||||
|
|
||||||
analytics.PnL30d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-30));
|
|
||||||
analytics.WinRate30d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-30));
|
|
||||||
|
|
||||||
analytics.PnL7d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-7));
|
|
||||||
analytics.WinRate7d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-7));
|
|
||||||
|
|
||||||
analytics.PnL24h = CalculatePnL(trades, DateTime.UtcNow.AddHours(-24));
|
|
||||||
analytics.WinRate24h = CalculateWinRate(trades, DateTime.UtcNow.AddHours(-24));
|
|
||||||
|
|
||||||
// Update the trader record too for easy sorting
|
|
||||||
var trader = await db.Traders.FindAsync(new object[] { traderId }, ct);
|
|
||||||
if (trader != null)
|
|
||||||
{
|
|
||||||
trader.TotalPnl = analytics.OverallPnL;
|
|
||||||
trader.WinRate = analytics.OverallWinRate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private decimal CalculatePnL(List<Trade> trades, DateTime? since)
|
|
||||||
{
|
|
||||||
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value) : trades;
|
|
||||||
|
|
||||||
// Very simplified: Sells - Buys
|
|
||||||
// Note: Real PnL should consider if the market resolved in their favor.
|
|
||||||
// For now, we use the raw trade amounts.
|
|
||||||
decimal pnl = 0;
|
|
||||||
foreach (var t in filtered)
|
|
||||||
{
|
|
||||||
if (t.Side == Predictalytics.Domain.Enums.TradeSide.Buy) pnl -= t.Amount;
|
|
||||||
else pnl += t.Amount;
|
|
||||||
}
|
|
||||||
return pnl;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decimal CalculateWinRate(List<Trade> trades, DateTime? since)
|
|
||||||
{
|
|
||||||
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value).ToList() : trades;
|
|
||||||
if (!filtered.Any()) return 0;
|
|
||||||
|
|
||||||
// Simplified: A "win" is a Sell at a higher price than the average Buy price?
|
|
||||||
// Actually, without proper position tracking, this is hard.
|
|
||||||
// Let's assume a "win" is any trade that closed a position in profit.
|
|
||||||
// For now, let's just return a placeholder or implement a basic logic.
|
|
||||||
// Since we don't have resolution data easily linked here, we'll return 0 or a dummy.
|
|
||||||
// Wait, if MarketOutcome is resolved and they held that outcome, it's a win.
|
|
||||||
|
|
||||||
// Let's just use 0 for now to avoid misleading data, or
|
|
||||||
// if we have MarketOutcomeId and it's resolved, we can check.
|
|
||||||
|
|
||||||
return 0; // Placeholder until more complex logic is added
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user