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