Files
Predictalytics/src/Predictalytics.Application/Services/FingerprintDriftCalculator.cs
T
Richard b21da1c2c7 @
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>
@
2026-07-23 21:21:21 +02:00

114 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
}
}
}