From eaccdadddfaa93020863c933fd973898a7d591df Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 23 Jul 2026 09:17:58 +0200 Subject: [PATCH] H2: strategy-fingerprint metrics (conviction/sizing edge + category concentration) Two deeper analyses, both pure from existing data (no new API cost): - ConvictionEdgePct: return% of the biggest-bet third minus the smallest-bet third of closed markets. Positive => sizing carries information (copy size-weighted); negative => overbets losers (red flag). CalculateMarketWinRates now emits per-market (invested, returnPct) pairs consumed by StrategyMetricsCalculator.ComputeConvictionEdge. - CategoryConcentration: Herfindahl index of category volume shares (specialist vs generalist), from the category-performance dict. Stored on TraderAnalytics, exposed on TraderDetailDto. Migration AddStrategyFingerprintMetrics. +7 unit tests (70 total, 1 skip). Co-Authored-By: Claude Opus 4.8 --- .../StrategyMetricsCalculatorTests.cs | 74 + .../DTOs/TraderDto.cs | 4 + .../Services/AnalyticsService.cs | 1 + .../Services/StrategyMetricsCalculator.cs | 51 + .../Entities/AnalyticsEntities.cs | 6 + ..._AddStrategyFingerprintMetrics.Designer.cs | 1290 +++++++++++++++++ ...723071430_AddStrategyFingerprintMetrics.cs | 39 + .../Migrations/AppDbContextModelSnapshot.cs | 6 + .../Services/PositionPnLEngine.cs | 30 +- 9 files changed, 1493 insertions(+), 8 deletions(-) create mode 100644 src/Predictalytics.Application.Tests/Services/StrategyMetricsCalculatorTests.cs create mode 100644 src/Predictalytics.Application/Services/StrategyMetricsCalculator.cs create mode 100644 src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.Designer.cs create mode 100644 src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.cs diff --git a/src/Predictalytics.Application.Tests/Services/StrategyMetricsCalculatorTests.cs b/src/Predictalytics.Application.Tests/Services/StrategyMetricsCalculatorTests.cs new file mode 100644 index 0000000..bbce8bb --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/StrategyMetricsCalculatorTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using Predictalytics.Application.Services; +using Xunit; + +namespace Predictalytics.Application.Tests.Services; + +public class StrategyMetricsCalculatorTests +{ + // ── Concentration (Herfindahl) ──────────────────────────────────────────── + [Fact] + public void Concentration_SingleCategory_IsOne() + { + Assert.Equal(1.0m, StrategyMetricsCalculator.ComputeConcentration(new[] { 500m })); + } + + [Fact] + public void Concentration_TwoEqualCategories_IsHalf() + { + // shares 0.5, 0.5 -> 0.25 + 0.25 = 0.5 + Assert.Equal(0.5m, StrategyMetricsCalculator.ComputeConcentration(new[] { 100m, 100m })); + } + + [Fact] + public void Concentration_SpecialistScoresHigherThanGeneralist() + { + var specialist = StrategyMetricsCalculator.ComputeConcentration(new[] { 900m, 50m, 50m }); + var generalist = StrategyMetricsCalculator.ComputeConcentration(new[] { 100m, 100m, 100m, 100m }); + Assert.True(specialist > generalist); + } + + [Fact] + public void Concentration_NoVolume_IsZero() + { + Assert.Equal(0m, StrategyMetricsCalculator.ComputeConcentration(new[] { 0m, 0m })); + } + + // ── Conviction / sizing edge ────────────────────────────────────────────── + [Fact] + public void Conviction_TooFewMarkets_IsNull() + { + var markets = new List<(decimal, decimal)> { (10m, 5m), (20m, 5m), (30m, 5m) }; + Assert.Null(StrategyMetricsCalculator.ComputeConvictionEdge(markets)); + } + + [Fact] + public void Conviction_BigBetsWinMore_IsPositive() + { + // small bets (invested 10-30) return ~0%, big bets (invested 100-120) return ~+40% + var markets = new List<(decimal Invested, decimal ReturnPct)> + { + (10m, 0m), (20m, 2m), (30m, -2m), + (50m, 5m), (60m, 3m), (70m, 4m), + (100m, 40m), (110m, 38m), (120m, 42m), + }; + var edge = StrategyMetricsCalculator.ComputeConvictionEdge(markets); + Assert.NotNull(edge); + Assert.True(edge > 30m); // big third avg ~40 minus small third avg ~0 + } + + [Fact] + public void Conviction_OverbetsLosers_IsNegative() + { + // big bets LOSE, small bets win -> negative conviction (a red flag) + var markets = new List<(decimal Invested, decimal ReturnPct)> + { + (10m, 20m), (20m, 25m), (30m, 22m), + (50m, 5m), (60m, 3m), (70m, 4m), + (100m, -30m), (110m, -35m), (120m, -28m), + }; + var edge = StrategyMetricsCalculator.ComputeConvictionEdge(markets); + Assert.NotNull(edge); + Assert.True(edge < 0m); + } +} diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index 8833d43..a98666a 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -70,6 +70,10 @@ public record TraderDetailDto( int LongestLosingStreakDays, decimal? ReturnOverMaxDrawdown, + // H2 Strategy fingerprint + decimal CategoryConcentration, + decimal? ConvictionEdgePct, + int Rank, bool IsOnWatchlist, DateTime CreatedAt, diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs index ceff508..5757743 100644 --- a/src/Predictalytics.Application/Services/AnalyticsService.cs +++ b/src/Predictalytics.Application/Services/AnalyticsService.cs @@ -256,6 +256,7 @@ public class AnalyticsService : IAnalyticsService s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0, a?.MedianWinReturnPct ?? 0, a?.AvgWinReturnPct ?? 0, a?.MedianLossReturnPct ?? 0, a?.AvgLossReturnPct ?? 0, a?.ProfitFactor, a?.MaxDrawdownUsd ?? 0, a?.PnlVolatilityUsd ?? 0, a?.LongestLosingStreakDays ?? 0, a?.ReturnOverMaxDrawdown, + a?.CategoryConcentration ?? 0, a?.ConvictionEdgePct, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt, trader.AiStrategySummary, trades.Select(MapTradeDto).ToList(), diff --git a/src/Predictalytics.Application/Services/StrategyMetricsCalculator.cs b/src/Predictalytics.Application/Services/StrategyMetricsCalculator.cs new file mode 100644 index 0000000..de7805d --- /dev/null +++ b/src/Predictalytics.Application/Services/StrategyMetricsCalculator.cs @@ -0,0 +1,51 @@ +namespace Predictalytics.Application.Services; + +/// +/// Deeper strategy-fingerprint metrics computed purely from a trader's closed markets and +/// category mix — no new data collection required. +/// +public static class StrategyMetricsCalculator +{ + /// + /// Category concentration via the Herfindahl–Hirschman Index over volume shares. + /// 1.0 = everything in a single category (specialist); approaches 1/n for an even spread + /// (generalist). Returns 0 when there is no volume. A specialist's edge is often more + /// trustworthy inside their niche and more suspect outside it. + /// + public static decimal ComputeConcentration(IEnumerable categoryVolumes) + { + var vols = categoryVolumes.Where(v => v > 0).ToList(); + var total = vols.Sum(); + if (total <= 0) return 0m; + + decimal hhi = 0m; + foreach (var v in vols) + { + var share = v / total; + hhi += share * share; + } + return System.Math.Round(hhi, 4); + } + + /// + /// Conviction / sizing edge: do the trader's BIGGEST bets outperform their smallest? + /// Splits closed markets into the top and bottom third by invested capital and returns + /// (avg return% of the big-bet third) − (avg return% of the small-bet third). + /// Positive ⇒ their sizing carries information (bigger conviction → better outcome), so a + /// copier should size-weight them; ≈0 ⇒ size is noise, copy flat; negative ⇒ they overbet + /// their losers (a red flag). Needs ≥ 6 closed markets; returns null otherwise. + /// + public static decimal? ComputeConvictionEdge(IReadOnlyList<(decimal Invested, decimal ReturnPct)> closedMarkets) + { + var valid = closedMarkets.Where(m => m.Invested > 0).OrderBy(m => m.Invested).ToList(); + if (valid.Count < 6) return null; + + int third = valid.Count / 3; + var smallBets = valid.Take(third).ToList(); + var bigBets = valid.Skip(valid.Count - third).ToList(); + + var smallAvg = smallBets.Average(m => m.ReturnPct); + var bigAvg = bigBets.Average(m => m.ReturnPct); + return System.Math.Round(bigAvg - smallAvg, 2); + } +} diff --git a/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs b/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs index bb6ac5e..14ad9b4 100644 --- a/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs +++ b/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs @@ -60,6 +60,12 @@ public class TraderAnalytics public decimal PnlVolatilityUsd { get; set; } public int LongestLosingStreakDays { get; set; } + // H2 Strategy fingerprint + /// Herfindahl index of category volume shares (0..1; 1 = single-category specialist). + public decimal CategoryConcentration { get; set; } + /// Return% of the biggest-bet third minus the smallest-bet third. Null = too few closed markets. + public decimal? ConvictionEdgePct { get; set; } + /// Calmar-like: profit per unit of worst drawdown. Null when there was no drawdown. public decimal? ReturnOverMaxDrawdown => MaxDrawdownUsd > 0 ? System.Math.Round(OverallPnL / MaxDrawdownUsd, 2) : null; diff --git a/src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.Designer.cs new file mode 100644 index 0000000..3ff8d64 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.Designer.cs @@ -0,0 +1,1290 @@ +// +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("20260723071430_AddStrategyFingerprintMetrics")] + partial class AddStrategyFingerprintMetrics + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("TraderId"); + + b.ToTable("Alerts"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobType"); + + b.HasIndex("Status"); + + b.HasIndex("TraderId"); + + b.ToTable("BackgroundJobs"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DbCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsClosed") + .HasColumnType("tinyint(1)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformEventId") + .HasColumnType("bigint"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Tags") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Platform", "PlatformEventId") + .IsUnique(); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DbCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("EventId") + .HasColumnType("int"); + + b.Property("FeeRateBps") + .HasColumnType("decimal(65,30)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsNegRisk") + .HasColumnType("tinyint(1)"); + + b.Property("IsResolved") + .HasColumnType("tinyint(1)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Liquidity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformMarketId") + .HasColumnType("bigint"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ResolutionOutcome") + .HasColumnType("longtext"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Volume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Volume24h") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("Platform", "PlatformMarketId") + .IsUnique(); + + b.ToTable("Markets"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b => + { + b.Property("MarketId") + .HasColumnType("int"); + + b.Property("AverageTradeSize") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BotActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UniqueTradersCount") + .HasColumnType("int"); + + b.HasKey("MarketId"); + + b.ToTable("MarketAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentPrice") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MarketId") + .HasColumnType("int"); + + b.Property("OutcomeIndex") + .HasColumnType("int"); + + b.Property("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.MarketOutcomePriceSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MarketOutcomeId", "Timestamp"); + + b.ToTable("MarketOutcomePriceSnapshots"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BaseUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SettingsJson") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("PlatformConfigs"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregatedCount") + .HasColumnType("int"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("AssetId") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("DbMarketId") + .HasColumnType("int"); + + b.Property("ExecutedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsContextEnriched") + .HasColumnType("tinyint(1)"); + + b.Property("MarketId") + .IsRequired() + .HasMaxLength(66) + .HasColumnType("varchar(66)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("OutcomeIndex") + .HasColumnType("int"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformTradeId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PostTradePrice1m") + .HasColumnType("decimal(18,4)"); + + b.Property("PreTradePrice1m") + .HasColumnType("decimal(18,4)"); + + b.Property("Price") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .HasColumnType("int"); + + b.Property("Size") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("TransactionHash") + .HasMaxLength(66) + .HasColumnType("varchar(66)"); + + b.Property("UsdcSize") + .HasColumnType("decimal(18,6)"); + + 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.TradeContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstimatedOrderType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("EstimatedSlippage") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("FollowerFillPrice10s") + .HasColumnType("decimal(65,30)"); + + b.Property("FollowerFillPrice60s") + .HasColumnType("decimal(65,30)"); + + b.Property("PriceAfter1m") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PriceBefore1m") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TradeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("TradeContexts"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AiStrategySummary") + .HasColumnType("longtext"); + + b.Property("AiStrategyUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IngestMode") + .HasColumnType("int"); + + b.Property("IsAutoDiscovered") + .HasColumnType("tinyint(1)"); + + b.Property("IsInitialImportComplete") + .HasColumnType("tinyint(1)"); + + b.Property("IsSuspectedBot") + .HasColumnType("tinyint(1)"); + + b.Property("LastAnalyzedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastApiErrorAt") + .HasColumnType("datetime(6)"); + + b.Property("LastPolledAt") + .HasColumnType("datetime(6)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ManualPriorityOverride") + .HasColumnType("int"); + + b.Property("MasterStatus") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Strategy") + .HasColumnType("int"); + + b.Property("Tier") + .HasColumnType("int"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("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("TraderId") + .HasColumnType("int"); + + b.Property("AvgLossReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("AvgWinReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CategoryConcentration") + .HasColumnType("decimal(65,30)"); + + b.Property("ConvictionEdgePct") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingCopyabilityScore") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingQualityScore") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingScore") + .HasColumnType("decimal(65,30)"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("EstimatedBankroll") + .HasColumnType("decimal(65,30)"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LongestLosingStreakDays") + .HasColumnType("int"); + + b.Property("MaxDrawdownUsd") + .HasColumnType("decimal(65,30)"); + + b.Property("MedianHoldDurationHours") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MedianLossReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MedianMarketVolumeUsd") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MedianPostFillDriftPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MedianWinReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetEdgeAfterFeesPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("OverallPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("OverallWinRate") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("P50PositionSize") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("P90PositionSize") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL24h") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL30d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL7d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnlVolatilityUsd") + .HasColumnType("decimal(65,30)"); + + b.Property("PriceBandProfileJson") + .HasColumnType("longtext"); + + b.Property("ProfitFactor") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Trades30d") + .HasColumnType("int"); + + b.Property("TradesPerWeek") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WinRate24h") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate30d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate7d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.HasKey("TraderId"); + + b.ToTable("TraderAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("TotalInvested") + .HasColumnType("decimal(65,30)"); + + b.Property("TotalPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("TotalVolume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("WinningTrades") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Category", "Subcategory") + .IsUnique(); + + b.ToTable("TraderCategoryPerformances"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderDailySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentBalance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Date") + .IsUnique(); + + b.ToTable("TraderDailySnapshots"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AvgCost") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("IsHistoryPruned") + .HasColumnType("tinyint(1)"); + + b.Property("LastAppliedTradeId") + .HasColumnType("bigint"); + + b.Property("LastTradeExecutedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("RealizedPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SharesHeld") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivityScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CombinedScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("QualityScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("TimingScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("VolumeScore") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("TraderId") + .IsUnique(); + + b.ToTable("TraderScores"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderTrait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ComputedAt") + .HasColumnType("datetime(6)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("Trait") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Trait") + .IsUnique(); + + b.ToTable("TraderTraits"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderWindowMetrics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AvgReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ClosedMarkets") + .HasColumnType("int"); + + b.Property("ComputedAt") + .HasColumnType("datetime(6)"); + + b.Property("MedianLossReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MedianWinReturnPct") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ProfitFactor") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("WinRate") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WindowEnd") + .HasColumnType("datetime(6)"); + + b.Property("WindowStart") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "WindowStart", "WindowEnd") + .IsUnique(); + + b.ToTable("TraderWindowMetrics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetime(6)"); + + b.Property("AlertsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("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.BackgroundJob", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("TraderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Trader"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => + { + b.HasOne("Predictalytics.Domain.Entities.Event", "Event") + .WithMany("Markets") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + 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.MarketOutcomePriceSnapshot", b => + { + b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome") + .WithMany() + .HasForeignKey("MarketOutcomeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MarketOutcome"); + }); + + 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.TradeContext", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trade", "Trade") + .WithOne("Context") + .HasForeignKey("Predictalytics.Domain.Entities.TradeContext", "TradeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trade"); + }); + + 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.TraderCategoryPerformance", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany("CategoryPerformances") + .HasForeignKey("TraderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trader"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderDailySnapshot", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("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.TraderTrait", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany("Traits") + .HasForeignKey("TraderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trader"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderWindowMetrics", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("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.Event", b => + { + b.Navigation("Markets"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => + { + b.Navigation("Analytics"); + + b.Navigation("Outcomes"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b => + { + b.Navigation("Context"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b => + { + b.Navigation("Analytics"); + + b.Navigation("CategoryPerformances"); + + b.Navigation("CurrentScore"); + + b.Navigation("Positions"); + + b.Navigation("Trades"); + + b.Navigation("Traits"); + + b.Navigation("WatchlistEntries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.cs b/src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.cs new file mode 100644 index 0000000..3b521c9 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260723071430_AddStrategyFingerprintMetrics.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddStrategyFingerprintMetrics : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CategoryConcentration", + table: "TraderAnalytics", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "ConvictionEdgePct", + table: "TraderAnalytics", + type: "decimal(65,30)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CategoryConcentration", + table: "TraderAnalytics"); + + migrationBuilder.DropColumn( + name: "ConvictionEdgePct", + table: "TraderAnalytics"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 4ac709f..26bc72c 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -652,6 +652,12 @@ namespace Predictalytics.Infrastructure.Migrations .HasPrecision(18, 4) .HasColumnType("decimal(18,4)"); + b.Property("CategoryConcentration") + .HasColumnType("decimal(65,30)"); + + b.Property("ConvictionEdgePct") + .HasColumnType("decimal(65,30)"); + b.Property("CopytradingCopyabilityScore") .HasColumnType("decimal(65,30)"); diff --git a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs index ecc9216..4f871cf 100644 --- a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs +++ b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs @@ -376,20 +376,26 @@ public class PositionPnLEngine : IPositionPnLEngine analytics.LongestLosingStreakDays = risk.LongestLosingStreakDays; // Calculate Win Rate and Return Pcts on Market level - var (winRateOverall, winRate30d, winRate7d, winRate24h, - medianWin, avgWin, medianLoss, avgLoss, profitFactor) = - CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h); + var (winRateOverall, winRate30d, winRate7d, winRate24h, + medianWin, avgWin, medianLoss, avgLoss, profitFactor) = + CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h, + out var closedMarketReturns); analytics.OverallWinRate = winRateOverall; analytics.WinRate30d = winRate30d; analytics.WinRate7d = winRate7d; analytics.WinRate24h = winRate24h; - + analytics.MedianWinReturnPct = medianWin; analytics.AvgWinReturnPct = avgWin; analytics.MedianLossReturnPct = medianLoss; analytics.AvgLossReturnPct = avgLoss; analytics.ProfitFactor = profitFactor; + + // H2: conviction/sizing edge — do the biggest bets outperform the smallest? + analytics.ConvictionEdgePct = + Predictalytics.Application.Services.StrategyMetricsCalculator.ComputeConvictionEdge(closedMarketReturns); + analytics.LastCalculatedAt = DateTime.UtcNow; // Sync back to Trader record for quick sorting / UI display @@ -411,7 +417,12 @@ public class PositionPnLEngine : IPositionPnLEngine .ToDictionaryAsync(tcp => (tcp.Category, tcp.Subcategory), ct); var newCatPerf = CalculateCategoryPerformances(trades, tempPositions); - + + // H2: how concentrated is the trader across categories (specialist vs generalist)? + analytics.CategoryConcentration = + Predictalytics.Application.Services.StrategyMetricsCalculator.ComputeConcentration( + newCatPerf.Values.Select(p => p.TotalVolume)); + foreach (var kvp in newCatPerf) { if (existingCatPerf.TryGetValue(kvp.Key, out var existing)) @@ -446,8 +457,10 @@ public class PositionPnLEngine : IPositionPnLEngine Dictionary finalPositions, DateTime cutoff30d, DateTime cutoff7d, - DateTime cutoff24h) + DateTime cutoff24h, + out List<(decimal Invested, decimal ReturnPct)> closedMarketReturns) { + closedMarketReturns = new List<(decimal, decimal)>(); // Group trades by Market var tradesByMarket = trades .Where(t => t.DbMarketId.HasValue || !string.IsNullOrEmpty(t.MarketId)) @@ -496,12 +509,13 @@ public class PositionPnLEngine : IPositionPnLEngine if (invested > 0) { var returnPct = marketPnl / invested * 100m; - if (returnPct > 0) + closedMarketReturns.Add((invested, returnPct)); + if (returnPct > 0) { winReturns.Add(returnPct); totalGrossWins += marketPnl; } - else if (returnPct < 0) + else if (returnPct < 0) { lossReturns.Add(returnPct); totalGrossLosses += Math.Abs(marketPnl);