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 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-23 21:21:21 +02:00
parent 2bec11d0a9
commit b21da1c2c7
13 changed files with 1957 additions and 0 deletions
@@ -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
@@ -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");
}
}
@@ -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<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options);
private static FingerprintSnapshotService CreateService(AppDbContext db)
=> new(db, NullLogger<FingerprintSnapshotService>.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");
}
}
@@ -0,0 +1,19 @@
using Predictalytics.Application.Services;
namespace Predictalytics.Application.Interfaces;
/// <summary>
/// Captures periodic strategy-fingerprint snapshots (the history that drift detection #3 and
/// edge-freshness #5 build on) and reads drift off that history.
/// </summary>
public interface IFingerprintSnapshotService
{
/// <summary>Capture a snapshot for every copy-relevant trader that has none in the last ~day. Returns the number captured.</summary>
Task<int> CaptureDueAsync(CancellationToken ct = default);
/// <summary>
/// Compare the trader's latest snapshot to the newest one at least <paramref name="baselineDays"/>
/// old. Returns null when there is not enough history to form a baseline.
/// </summary>
Task<FingerprintDriftResult?> GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default);
}
@@ -0,0 +1,113 @@
using System.Text.Json;
using Predictalytics.Domain.Entities;
namespace Predictalytics.Application.Services;
/// <summary>One fingerprint dimension that has drifted between a baseline and the current snapshot.</summary>
public sealed record FingerprintDriftDimension(string Dimension, string Detail, decimal Magnitude);
/// <summary>Result of comparing a baseline fingerprint snapshot to the current one.</summary>
public sealed record FingerprintDriftResult(
bool HasDrifted,
DateTime BaselineAt,
DateTime CurrentAt,
IReadOnlyList<FingerprintDriftDimension> Dimensions);
/// <summary>
/// Pure comparison of two <see cref="TraderFingerprintSnapshot"/> rows. Encodes the "is this master
/// still the same trader?" rules for strategy-drift detection (#3). No DB access — fully unit-tested.
/// </summary>
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<FingerprintDriftDimension>();
// 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<string, decimal> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<Dictionary<string, decimal>>(json) ?? new(); }
catch { return new(); }
}
}
private static (decimal Jaccard, List<string> Added, List<string> Removed) TraitSetDistance(string? baselineJson, string? currentJson)
{
var a = ParseSet(baselineJson);
var b = ParseSet(currentJson);
var union = new HashSet<string>(a); union.UnionWith(b);
if (union.Count == 0) return (0m, new(), new());
var intersection = new HashSet<string>(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<string> ParseSet(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return new HashSet<string>(JsonSerializer.Deserialize<List<string>>(json) ?? new()); }
catch { return new(); }
}
}
}
@@ -0,0 +1,42 @@
namespace Predictalytics.Domain.Entities;
/// <summary>
/// A point-in-time capture of a trader's strategy fingerprint. Unlike <see cref="TraderAnalytics"/>
/// (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.
/// </summary>
public class TraderFingerprintSnapshot
{
public int Id { get; set; }
public int TraderId { get; set; }
/// <summary>UTC timestamp of the capture.</summary>
public DateTime CapturedAt { get; set; } = DateTime.UtcNow;
// ── Fingerprint dimensions (mirrored from TraderAnalytics at capture time) ──
/// <summary>Copytrading suitability score (0-100) at capture time.</summary>
public decimal CopytradingScore { get; set; }
/// <summary>Herfindahl index of category volume shares (0..1; 1 = single-category specialist).</summary>
public decimal CategoryConcentration { get; set; }
/// <summary>Conviction edge: return% of the biggest-bet third minus the smallest-bet third. Null when undefined.</summary>
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; }
/// <summary>JSON map of category =&gt; volume share (0..1) at capture time, for category-mix drift.</summary>
public string? CategoryMixJson { get; set; }
/// <summary>JSON array of the trait keys present at capture time, for trait-set drift.</summary>
public string? TraitSetJson { get; set; }
// Navigation
public Trader Trader { get; set; } = null!;
}
@@ -26,6 +26,7 @@ public class AppDbContext : DbContext
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
public DbSet<InsiderWatch> InsiderWatches => Set<InsiderWatch>();
public DbSet<TraderFingerprintSnapshot> TraderFingerprintSnapshots => Set<TraderFingerprintSnapshot>();
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<TraderFingerprintSnapshot>(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<Alert>(e =>
{
@@ -76,6 +76,7 @@ public static class DependencyInjection
services.AddScoped<IDiscoveryService, DiscoveryService>();
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAnalyticsService, AnalyticsService>();
services.AddScoped<IFingerprintSnapshotService, FingerprintSnapshotService>();
services.AddScoped<ICopytradingEstimator, CopytradingEstimator>();
services.AddScoped<ICopytradingBacktestHarness, CopytradingBacktestHarness>();
services.AddScoped<WatchlistService>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFingerprintSnapshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TraderFingerprintSnapshots",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
CapturedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CopytradingScore = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CategoryConcentration = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ConvictionEdgePct = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
P50PositionSize = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
P90PositionSize = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
MedianHoldDurationHours = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
TradesPerWeek = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CategoryMixJson = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
TraitSetJson = table.Column<string>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TraderFingerprintSnapshots");
}
}
}
@@ -869,6 +869,54 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("TraderDailySnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderFingerprintSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CapturedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CategoryConcentration")
.HasColumnType("decimal(65,30)");
b.Property<string>("CategoryMixJson")
.HasColumnType("longtext");
b.Property<decimal?>("ConvictionEdgePct")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("MedianHoldDurationHours")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("P50PositionSize")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("P90PositionSize")
.HasColumnType("decimal(65,30)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("TradesPerWeek")
.HasColumnType("decimal(65,30)");
b.Property<string>("TraitSetJson")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("TraderId", "CapturedAt");
b.ToTable("TraderFingerprintSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("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")
@@ -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;
/// <summary>
/// Writes the strategy-fingerprint time series (<see cref="TraderFingerprintSnapshot"/>) 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.
/// </summary>
public class FingerprintSnapshotService : IFingerprintSnapshotService
{
private readonly AppDbContext _db;
private readonly ILogger<FingerprintSnapshotService> _logger;
/// <summary>Only snapshot traders worth monitoring for drift (i.e. plausible copy candidates).</summary>
private const decimal MinCopytradingScore = 40m;
/// <summary>Don't capture more than one snapshot per trader within this window.</summary>
private static readonly TimeSpan MinCaptureInterval = TimeSpan.FromHours(20);
public FingerprintSnapshotService(AppDbContext db, ILogger<FingerprintSnapshotService> logger)
{
_db = db;
_logger = logger;
}
public async Task<int> 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<FingerprintDriftResult?> 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);
}
}
@@ -39,9 +39,12 @@ public class ScoringAndAlertsWorker : BackgroundService
{
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
var fingerprintSnapshots = scope.ServiceProvider.GetRequiredService<IFingerprintSnapshotService>();
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.");