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