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
@@ -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(); }
}
}
}