From b21da1c2c76115298a914935ab5f57c06023ddd3 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 23 Jul 2026 21:21:21 +0200 Subject: [PATCH] @ Fingerprint-snapshot foundation + strategy-drift calculator (#3/#5 groundwork) TraderAnalytics is one row per trader, overwritten every recalculation, so there is no history to detect strategy drift (#3) or edge fade (#5) against. Add the missing time series: - TraderFingerprintSnapshot entity (score, category concentration, conviction, P50/P90 sizing, hold duration, trades/week, category-mix JSON, trait-set JSON) + migration AddFingerprintSnapshots (indexed by TraderId, CapturedAt). - FingerprintSnapshotService (Infrastructure): CaptureDueAsync snapshots every copy-relevant trader (CopytradingScore >= 40) at most ~once/day; wired into ScoringAndAlertsWorker. GetDriftAsync reads latest-vs-baseline drift. - FingerprintDriftCalculator (pure, Application): flags score drop, concentration shift, sizing jump, conviction sign-flip, category-mix TVD, trait-set change. - GET /api/traders/{id}/fingerprint-drift?baselineDays=14 read endpoint. - Tests: drift calculator (4 scenarios) + capture service (copy-relevance, throttle, drift read). This is the shared foundation both #3 (drift alarm) and #5 (edge freshness) build on. Co-Authored-By: Claude Opus 4.8 @ --- .../Endpoints/TraderEndpoints.cs | 7 + .../FingerprintDriftCalculatorTests.cs | 67 + .../FingerprintSnapshotServiceTests.cs | 78 + .../Interfaces/IFingerprintSnapshotService.cs | 19 + .../Services/FingerprintDriftCalculator.cs | 113 ++ .../Entities/TraderFingerprintSnapshot.cs | 42 + .../Data/AppDbContext.cs | 9 + .../DependencyInjection.cs | 1 + ...185636_AddFingerprintSnapshots.Designer.cs | 1385 +++++++++++++++++ .../20260723185636_AddFingerprintSnapshots.cs | 60 + .../Migrations/AppDbContextModelSnapshot.cs | 59 + .../Services/FingerprintSnapshotService.cs | 114 ++ .../Services/ScoringAndAlertsWorker.cs | 3 + 13 files changed, 1957 insertions(+) create mode 100644 src/Predictalytics.Application.Tests/Services/FingerprintDriftCalculatorTests.cs create mode 100644 src/Predictalytics.Application.Tests/Services/FingerprintSnapshotServiceTests.cs create mode 100644 src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs create mode 100644 src/Predictalytics.Application/Services/FingerprintDriftCalculator.cs create mode 100644 src/Predictalytics.Domain/Entities/TraderFingerprintSnapshot.cs create mode 100644 src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.Designer.cs create mode 100644 src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.cs create mode 100644 src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs index 567f9bc..74aab38 100644 --- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs @@ -98,6 +98,13 @@ public static class TraderEndpoints return Results.Ok(profile); }); + // Strategy-drift: compare a master's latest fingerprint to a baseline ~N days ago (#3). + group.MapGet("/{id:int}/fingerprint-drift", async (int id, int? baselineDays, IFingerprintSnapshotService svc, CancellationToken ct) => + { + var drift = await svc.GetDriftAsync(id, baselineDays ?? 14, ct); + return drift is not null ? Results.Ok(drift) : Results.NoContent(); + }); + group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) => { var positionsA = await db.TraderPositions diff --git a/src/Predictalytics.Application.Tests/Services/FingerprintDriftCalculatorTests.cs b/src/Predictalytics.Application.Tests/Services/FingerprintDriftCalculatorTests.cs new file mode 100644 index 0000000..8d9a9d0 --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/FingerprintDriftCalculatorTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using Predictalytics.Application.Services; +using Predictalytics.Domain.Entities; +using Xunit; + +namespace Predictalytics.Application.Tests.Services; + +public class FingerprintDriftCalculatorTests +{ + private static TraderFingerprintSnapshot Snap( + decimal score = 70m, decimal conc = 0.5m, decimal? conv = 5m, + decimal p50 = 100m, string? mix = null, string? traits = null, int daysAgo = 0) + => new() + { + TraderId = 1, + CapturedAt = DateTime.UtcNow.AddDays(-daysAgo), + CopytradingScore = score, + CategoryConcentration = conc, + ConvictionEdgePct = conv, + P50PositionSize = p50, + CategoryMixJson = mix, + TraitSetJson = traits + }; + + [Fact] + public void IdenticalFingerprints_DoNotDrift() + { + var b = Snap(mix: "{\"Sports\":1.0}", traits: "[\"scalper\"]", daysAgo: 14); + var c = Snap(mix: "{\"Sports\":1.0}", traits: "[\"scalper\"]"); + var r = FingerprintDriftCalculator.Compare(b, c); + Assert.False(r.HasDrifted); + Assert.Empty(r.Dimensions); + } + + [Fact] + public void ScoreDrop_IsFlagged_ButScoreGainIsNot() + { + var dropped = FingerprintDriftCalculator.Compare(Snap(score: 70m, daysAgo: 14), Snap(score: 50m)); + Assert.Contains(dropped.Dimensions, d => d.Dimension == "score"); + + var gained = FingerprintDriftCalculator.Compare(Snap(score: 50m, daysAgo: 14), Snap(score: 70m)); + Assert.DoesNotContain(gained.Dimensions, d => d.Dimension == "score"); + } + + [Fact] + public void CategoryMixReversal_FlagsCategoryMix() + { + var b = Snap(mix: "{\"Sports\":1.0}", daysAgo: 14); + var c = Snap(mix: "{\"Politics\":1.0}"); + var r = FingerprintDriftCalculator.Compare(b, c); + Assert.Contains(r.Dimensions, d => d.Dimension == "category_mix"); + } + + [Fact] + public void TraitSetChange_And_SizingJump_And_ConvictionFlip_AreFlagged() + { + var b = Snap(conv: 6m, p50: 100m, traits: "[\"scalper\"]", daysAgo: 14); + var c = Snap(conv: -4m, p50: 350m, traits: "[\"whale\",\"holds_to_resolution\"]"); + var r = FingerprintDriftCalculator.Compare(b, c); + + Assert.True(r.HasDrifted); + Assert.Contains(r.Dimensions, d => d.Dimension == "trait_set"); + Assert.Contains(r.Dimensions, d => d.Dimension == "sizing"); + Assert.Contains(r.Dimensions, d => d.Dimension == "conviction"); + } +} diff --git a/src/Predictalytics.Application.Tests/Services/FingerprintSnapshotServiceTests.cs b/src/Predictalytics.Application.Tests/Services/FingerprintSnapshotServiceTests.cs new file mode 100644 index 0000000..239cfd7 --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/FingerprintSnapshotServiceTests.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Predictalytics.Domain.Entities; +using Predictalytics.Infrastructure.Data; +using Predictalytics.Infrastructure.Services; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Predictalytics.Application.Tests.Services; + +public class FingerprintSnapshotServiceTests +{ + private static AppDbContext CreateDbContext() + => new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options); + + private static FingerprintSnapshotService CreateService(AppDbContext db) + => new(db, NullLogger.Instance); + + [Fact] + public async Task CaptureDueAsync_CapturesCopyRelevantOnly_AndIsIdempotentWithinWindow() + { + using var db = CreateDbContext(); + + var master = new Trader { Id = 1, PlatformUserId = "0xM", DisplayName = "Master", + Analytics = new TraderAnalytics { TraderId = 1, CopytradingScore = 60m, CategoryConcentration = 0.4m, P50PositionSize = 100m } }; + master.Traits.Add(new TraderTrait { TraderId = 1, Trait = "holds_to_resolution", Value = 1m }); + // Below the copy-relevance threshold -> must NOT be snapshotted. + var weak = new Trader { Id = 2, PlatformUserId = "0xW", DisplayName = "Weak", + Analytics = new TraderAnalytics { TraderId = 2, CopytradingScore = 20m } }; + db.Traders.AddRange(master, weak); + await db.SaveChangesAsync(); + + var svc = CreateService(db); + var first = await svc.CaptureDueAsync(); + + Assert.Equal(1, first); + Assert.Single(db.TraderFingerprintSnapshots); + Assert.Equal(1, db.TraderFingerprintSnapshots.Single().TraderId); + Assert.Contains("holds_to_resolution", db.TraderFingerprintSnapshots.Single().TraitSetJson); + + // Second run within the throttle window captures nothing. + var second = await svc.CaptureDueAsync(); + Assert.Equal(0, second); + Assert.Single(db.TraderFingerprintSnapshots); + } + + [Fact] + public async Task GetDriftAsync_NullWithoutBaseline_ThenDetectsScoreDrop() + { + using var db = CreateDbContext(); + db.Traders.Add(new Trader { Id = 5, PlatformUserId = "0xD", DisplayName = "Drifter" }); + // Only a recent snapshot -> no baseline yet. + db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot + { + TraderId = 5, CapturedAt = DateTime.UtcNow, CopytradingScore = 45m + }); + await db.SaveChangesAsync(); + + var svc = CreateService(db); + Assert.Null(await svc.GetDriftAsync(5, baselineDays: 14)); + + // Add an older baseline with a much higher score. + db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot + { + TraderId = 5, CapturedAt = DateTime.UtcNow.AddDays(-20), CopytradingScore = 75m + }); + await db.SaveChangesAsync(); + + var drift = await svc.GetDriftAsync(5, baselineDays: 14); + Assert.NotNull(drift); + Assert.True(drift!.HasDrifted); + Assert.Contains(drift.Dimensions, d => d.Dimension == "score"); + } +} diff --git a/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs b/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs new file mode 100644 index 0000000..e4df937 --- /dev/null +++ b/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs @@ -0,0 +1,19 @@ +using Predictalytics.Application.Services; + +namespace Predictalytics.Application.Interfaces; + +/// +/// Captures periodic strategy-fingerprint snapshots (the history that drift detection #3 and +/// edge-freshness #5 build on) and reads drift off that history. +/// +public interface IFingerprintSnapshotService +{ + /// Capture a snapshot for every copy-relevant trader that has none in the last ~day. Returns the number captured. + Task CaptureDueAsync(CancellationToken ct = default); + + /// + /// Compare the trader's latest snapshot to the newest one at least + /// old. Returns null when there is not enough history to form a baseline. + /// + Task GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default); +} diff --git a/src/Predictalytics.Application/Services/FingerprintDriftCalculator.cs b/src/Predictalytics.Application/Services/FingerprintDriftCalculator.cs new file mode 100644 index 0000000..6121f17 --- /dev/null +++ b/src/Predictalytics.Application/Services/FingerprintDriftCalculator.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Predictalytics.Domain.Entities; + +namespace Predictalytics.Application.Services; + +/// One fingerprint dimension that has drifted between a baseline and the current snapshot. +public sealed record FingerprintDriftDimension(string Dimension, string Detail, decimal Magnitude); + +/// Result of comparing a baseline fingerprint snapshot to the current one. +public sealed record FingerprintDriftResult( + bool HasDrifted, + DateTime BaselineAt, + DateTime CurrentAt, + IReadOnlyList Dimensions); + +/// +/// Pure comparison of two rows. Encodes the "is this master +/// still the same trader?" rules for strategy-drift detection (#3). No DB access — fully unit-tested. +/// +public static class FingerprintDriftCalculator +{ + // Thresholds (documented, tunable). A dimension is flagged only past these. + public const decimal ScoreDropPoints = 15m; // copytrading score fell by >= this many points + public const decimal ConcentrationShift = 0.25m; // Herfindahl moved by >= this (0..1 scale) + public const decimal SizingRatio = 2.0m; // P50 position size grew/shrank by >= this factor + public const decimal CategoryMixTvd = 0.35m; // total-variation distance of category mix + public const double TraitJaccardDistance = 0.5; // trait-set Jaccard distance + + public static FingerprintDriftResult Compare(TraderFingerprintSnapshot baseline, TraderFingerprintSnapshot current) + { + var dims = new List(); + + // 1. Copytrading score — only a DROP matters for protecting copiers. + var scoreDelta = current.CopytradingScore - baseline.CopytradingScore; + if (-scoreDelta >= ScoreDropPoints) + dims.Add(new("score", $"Copytrading-Score {baseline.CopytradingScore:F0} → {current.CopytradingScore:F0}", Math.Abs(scoreDelta))); + + // 2. Category concentration — specialist ↔ generalist shift in either direction. + var concDelta = Math.Abs(current.CategoryConcentration - baseline.CategoryConcentration); + if (concDelta >= ConcentrationShift) + dims.Add(new("concentration", $"Kategorie-Konzentration {baseline.CategoryConcentration:F2} → {current.CategoryConcentration:F2}", concDelta)); + + // 3. Position sizing — a large jump in typical bet size. + if (baseline.P50PositionSize > 0 && current.P50PositionSize > 0) + { + var ratio = current.P50PositionSize / baseline.P50PositionSize; + if (ratio >= SizingRatio || ratio <= 1m / SizingRatio) + dims.Add(new("sizing", $"Typische Positionsgröße ${baseline.P50PositionSize:N0} → ${current.P50PositionSize:N0}", ratio >= 1m ? ratio : 1m / ratio)); + } + + // 4. Conviction edge — a sign flip means their big bets stopped outperforming. + var bConv = baseline.ConvictionEdgePct; + var cConv = current.ConvictionEdgePct; + if (bConv is > 0m && cConv is < 0m) + dims.Add(new("conviction", $"Conviction-Edge {bConv:F1}% → {cConv:F1}% (Vorzeichenwechsel)", Math.Abs((bConv ?? 0) - (cConv ?? 0)))); + + // 5. Category mix — total-variation distance between the two volume-share distributions. + var tvd = CategoryMixTvd_(baseline.CategoryMixJson, current.CategoryMixJson); + if (tvd >= CategoryMixTvd) + dims.Add(new("category_mix", $"Kategorie-Mix verschoben (TVD {tvd:F2})", tvd)); + + // 6. Trait set — Jaccard distance between the two trait sets. + var (jaccard, added, removed) = TraitSetDistance(baseline.TraitSetJson, current.TraitSetJson); + if (jaccard >= (decimal)TraitJaccardDistance) + { + var detail = $"Trait-Set geändert (+[{string.Join(", ", added)}] −[{string.Join(", ", removed)}])"; + dims.Add(new("trait_set", detail, jaccard)); + } + + return new FingerprintDriftResult(dims.Count > 0, baseline.CapturedAt, current.CapturedAt, dims); + } + + private static decimal CategoryMixTvd_(string? baselineJson, string? currentJson) + { + var a = Parse(baselineJson); + var b = Parse(currentJson); + if (a.Count == 0 || b.Count == 0) return 0m; + + decimal sum = 0m; + foreach (var key in a.Keys.Union(b.Keys)) + sum += Math.Abs(a.GetValueOrDefault(key) - b.GetValueOrDefault(key)); + return sum / 2m; // total-variation distance of two probability distributions + + static Dictionary Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + } + } + + private static (decimal Jaccard, List Added, List Removed) TraitSetDistance(string? baselineJson, string? currentJson) + { + var a = ParseSet(baselineJson); + var b = ParseSet(currentJson); + var union = new HashSet(a); union.UnionWith(b); + if (union.Count == 0) return (0m, new(), new()); + + var intersection = new HashSet(a); intersection.IntersectWith(b); + var jaccard = 1m - (decimal)intersection.Count / union.Count; + + var added = b.Except(a).OrderBy(x => x).ToList(); + var removed = a.Except(b).OrderBy(x => x).ToList(); + return (jaccard, added, removed); + + static HashSet ParseSet(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return new HashSet(JsonSerializer.Deserialize>(json) ?? new()); } + catch { return new(); } + } + } +} diff --git a/src/Predictalytics.Domain/Entities/TraderFingerprintSnapshot.cs b/src/Predictalytics.Domain/Entities/TraderFingerprintSnapshot.cs new file mode 100644 index 0000000..9203e24 --- /dev/null +++ b/src/Predictalytics.Domain/Entities/TraderFingerprintSnapshot.cs @@ -0,0 +1,42 @@ +namespace Predictalytics.Domain.Entities; + +/// +/// A point-in-time capture of a trader's strategy fingerprint. Unlike +/// (one row per trader, overwritten every recalculation), these accumulate over time to form the +/// history that strategy-drift detection (#3) and edge-freshness (#5) compare against a baseline. +/// Captured roughly daily for copy-relevant traders. +/// +public class TraderFingerprintSnapshot +{ + public int Id { get; set; } + + public int TraderId { get; set; } + + /// UTC timestamp of the capture. + public DateTime CapturedAt { get; set; } = DateTime.UtcNow; + + // ── Fingerprint dimensions (mirrored from TraderAnalytics at capture time) ── + + /// Copytrading suitability score (0-100) at capture time. + public decimal CopytradingScore { get; set; } + + /// Herfindahl index of category volume shares (0..1; 1 = single-category specialist). + public decimal CategoryConcentration { get; set; } + + /// Conviction edge: return% of the biggest-bet third minus the smallest-bet third. Null when undefined. + public decimal? ConvictionEdgePct { get; set; } + + public decimal P50PositionSize { get; set; } + public decimal P90PositionSize { get; set; } + public decimal MedianHoldDurationHours { get; set; } + public decimal TradesPerWeek { get; set; } + + /// JSON map of category => volume share (0..1) at capture time, for category-mix drift. + public string? CategoryMixJson { get; set; } + + /// JSON array of the trait keys present at capture time, for trait-set drift. + public string? TraitSetJson { get; set; } + + // Navigation + public Trader Trader { get; set; } = null!; +} diff --git a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs index 6f85412..c7339a2 100644 --- a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs +++ b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs @@ -26,6 +26,7 @@ public class AppDbContext : DbContext public DbSet TraderTraits => Set(); public DbSet TraderWindowMetrics => Set(); public DbSet InsiderWatches => Set(); + public DbSet TraderFingerprintSnapshots => Set(); private readonly bool _isReadOnly; @@ -216,6 +217,14 @@ public class AppDbContext : DbContext e.HasOne(i => i.Trader).WithMany().HasForeignKey(i => i.TraderId); }); + // TraderFingerprintSnapshot (time-series; many rows per trader) + mb.Entity(e => + { + e.HasKey(s => s.Id); + e.HasIndex(s => new { s.TraderId, s.CapturedAt }); + e.HasOne(s => s.Trader).WithMany().HasForeignKey(s => s.TraderId); + }); + // Alert mb.Entity(e => { diff --git a/src/Predictalytics.Infrastructure/DependencyInjection.cs b/src/Predictalytics.Infrastructure/DependencyInjection.cs index c5ffebb..79b0077 100644 --- a/src/Predictalytics.Infrastructure/DependencyInjection.cs +++ b/src/Predictalytics.Infrastructure/DependencyInjection.cs @@ -76,6 +76,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.Designer.cs new file mode 100644 index 0000000..78736e2 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.Designer.cs @@ -0,0 +1,1385 @@ +// +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("20260723185636_AddFingerprintSnapshots")] + partial class AddFingerprintSnapshots + { + /// + 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.InsiderWatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FirstDetectedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastAlertedTradeAt") + .HasColumnType("datetime(6)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId") + .IsUnique(); + + b.ToTable("InsiderWatches"); + }); + + 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.TraderFingerprintSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CapturedAt") + .HasColumnType("datetime(6)"); + + b.Property("CategoryConcentration") + .HasColumnType("decimal(65,30)"); + + b.Property("CategoryMixJson") + .HasColumnType("longtext"); + + b.Property("ConvictionEdgePct") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingScore") + .HasColumnType("decimal(65,30)"); + + b.Property("MedianHoldDurationHours") + .HasColumnType("decimal(65,30)"); + + b.Property("P50PositionSize") + .HasColumnType("decimal(65,30)"); + + b.Property("P90PositionSize") + .HasColumnType("decimal(65,30)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("TradesPerWeek") + .HasColumnType("decimal(65,30)"); + + b.Property("TraitSetJson") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "CapturedAt"); + + b.ToTable("TraderFingerprintSnapshots"); + }); + + 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.InsiderWatch", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("TraderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.TraderFingerprintSnapshot", 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/20260723185636_AddFingerprintSnapshots.cs b/src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.cs new file mode 100644 index 0000000..02fad2e --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260723185636_AddFingerprintSnapshots.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddFingerprintSnapshots : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TraderFingerprintSnapshots", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TraderId = table.Column(type: "int", nullable: false), + CapturedAt = table.Column(type: "datetime(6)", nullable: false), + CopytradingScore = table.Column(type: "decimal(65,30)", nullable: false), + CategoryConcentration = table.Column(type: "decimal(65,30)", nullable: false), + ConvictionEdgePct = table.Column(type: "decimal(65,30)", nullable: true), + P50PositionSize = table.Column(type: "decimal(65,30)", nullable: false), + P90PositionSize = table.Column(type: "decimal(65,30)", nullable: false), + MedianHoldDurationHours = table.Column(type: "decimal(65,30)", nullable: false), + TradesPerWeek = table.Column(type: "decimal(65,30)", nullable: false), + CategoryMixJson = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + TraitSetJson = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_TraderFingerprintSnapshots", x => x.Id); + table.ForeignKey( + name: "FK_TraderFingerprintSnapshots_Traders_TraderId", + column: x => x.TraderId, + principalTable: "Traders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_TraderFingerprintSnapshots_TraderId_CapturedAt", + table: "TraderFingerprintSnapshots", + columns: new[] { "TraderId", "CapturedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TraderFingerprintSnapshots"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 05dd5b1..5053c62 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -869,6 +869,54 @@ namespace Predictalytics.Infrastructure.Migrations b.ToTable("TraderDailySnapshots"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderFingerprintSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CapturedAt") + .HasColumnType("datetime(6)"); + + b.Property("CategoryConcentration") + .HasColumnType("decimal(65,30)"); + + b.Property("CategoryMixJson") + .HasColumnType("longtext"); + + b.Property("ConvictionEdgePct") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingScore") + .HasColumnType("decimal(65,30)"); + + b.Property("MedianHoldDurationHours") + .HasColumnType("decimal(65,30)"); + + b.Property("P50PositionSize") + .HasColumnType("decimal(65,30)"); + + b.Property("P90PositionSize") + .HasColumnType("decimal(65,30)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("TradesPerWeek") + .HasColumnType("decimal(65,30)"); + + b.Property("TraitSetJson") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "CapturedAt"); + + b.ToTable("TraderFingerprintSnapshots"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => { b.Property("Id") @@ -1221,6 +1269,17 @@ namespace Predictalytics.Infrastructure.Migrations b.Navigation("Trader"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderFingerprintSnapshot", 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") diff --git a/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs b/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs new file mode 100644 index 0000000..b727853 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Predictalytics.Application.Interfaces; +using Predictalytics.Application.Services; +using Predictalytics.Domain.Entities; +using Predictalytics.Infrastructure.Data; +using System.Text.Json; + +namespace Predictalytics.Infrastructure.Services; + +/// +/// Writes the strategy-fingerprint time series () from the +/// current persisted analytics, and reads drift off that history. Capture is scoped to copy-relevant +/// traders and rate-limited to about one row per trader per day so the table stays bounded. +/// +public class FingerprintSnapshotService : IFingerprintSnapshotService +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + /// Only snapshot traders worth monitoring for drift (i.e. plausible copy candidates). + private const decimal MinCopytradingScore = 40m; + + /// Don't capture more than one snapshot per trader within this window. + private static readonly TimeSpan MinCaptureInterval = TimeSpan.FromHours(20); + + public FingerprintSnapshotService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task CaptureDueAsync(CancellationToken ct = default) + { + var now = DateTime.UtcNow; + var cutoff = now - MinCaptureInterval; + + // Traders already snapshotted within the window are skipped. + var recentlyCaptured = await _db.TraderFingerprintSnapshots + .Where(s => s.CapturedAt >= cutoff) + .Select(s => s.TraderId) + .Distinct() + .ToListAsync(ct); + var skip = recentlyCaptured.ToHashSet(); + + var candidates = await _db.Traders + .Include(t => t.Analytics) + .Include(t => t.Traits) + .Include(t => t.CategoryPerformances) + .Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= MinCopytradingScore) + .ToListAsync(ct); + + int captured = 0; + foreach (var trader in candidates) + { + if (skip.Contains(trader.Id)) continue; + var a = trader.Analytics!; + + _db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot + { + TraderId = trader.Id, + CapturedAt = now, + CopytradingScore = a.CopytradingScore, + CategoryConcentration = a.CategoryConcentration, + ConvictionEdgePct = a.ConvictionEdgePct, + P50PositionSize = a.P50PositionSize, + P90PositionSize = a.P90PositionSize, + MedianHoldDurationHours = a.MedianHoldDurationHours, + TradesPerWeek = a.TradesPerWeek, + CategoryMixJson = BuildCategoryMixJson(trader), + TraitSetJson = JsonSerializer.Serialize(trader.Traits.Select(tr => tr.Trait).OrderBy(x => x).ToList()) + }); + captured++; + } + + if (captured > 0) + { + await _db.SaveChangesAsync(ct); + _logger.LogInformation("📸 Fingerprint snapshots captured for {Count} traders", captured); + } + + return captured; + } + + public async Task GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default) + { + var latest = await _db.TraderFingerprintSnapshots + .Where(s => s.TraderId == traderId) + .OrderByDescending(s => s.CapturedAt) + .FirstOrDefaultAsync(ct); + if (latest == null) return null; + + var baselineCutoff = latest.CapturedAt.AddDays(-baselineDays); + var baseline = await _db.TraderFingerprintSnapshots + .Where(s => s.TraderId == traderId && s.CapturedAt <= baselineCutoff) + .OrderByDescending(s => s.CapturedAt) + .FirstOrDefaultAsync(ct); + if (baseline == null || baseline.Id == latest.Id) return null; + + return FingerprintDriftCalculator.Compare(baseline, latest); + } + + private static string? BuildCategoryMixJson(Trader trader) + { + var perfs = trader.CategoryPerformances; + if (perfs == null || perfs.Count == 0) return null; + + var total = perfs.Sum(p => p.TotalVolume); + if (total <= 0) return null; + + var mix = perfs.ToDictionary(p => p.Category.ToString(), p => p.TotalVolume / total); + return JsonSerializer.Serialize(mix); + } +} diff --git a/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs b/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs index 3b64fcf..0664934 100644 --- a/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs +++ b/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs @@ -39,9 +39,12 @@ public class ScoringAndAlertsWorker : BackgroundService { var scoringService = scope.ServiceProvider.GetRequiredService(); var alertService = scope.ServiceProvider.GetRequiredService(); + var fingerprintSnapshots = scope.ServiceProvider.GetRequiredService(); await scoringService.RecalculateAllScoresAsync(stoppingToken); await alertService.EvaluateAlertsAsync(stoppingToken); + // Capture the strategy-fingerprint time series (self-throttled to ~1/trader/day). + await fingerprintSnapshots.CaptureDueAsync(stoppingToken); } _logger.LogInformation("📈 ScoringAndAlertsWorker: Recalculation cycle complete.");