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> @
114 lines
5.7 KiB
C#
114 lines
5.7 KiB
C#
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(); }
|
||
}
|
||
}
|
||
}
|