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);