@
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:
@@ -98,6 +98,13 @@ public static class TraderEndpoints
|
|||||||
return Results.Ok(profile);
|
return Results.Ok(profile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Strategy-drift: compare a master's latest fingerprint to a baseline ~N days ago (#3).
|
||||||
|
group.MapGet("/{id:int}/fingerprint-drift", async (int id, int? baselineDays, IFingerprintSnapshotService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var drift = await svc.GetDriftAsync(id, baselineDays ?? 14, ct);
|
||||||
|
return drift is not null ? Results.Ok(drift) : Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var positionsA = await db.TraderPositions
|
var positionsA = await db.TraderPositions
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Predictalytics.Application.Services;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Tests.Services;
|
||||||
|
|
||||||
|
public class FingerprintDriftCalculatorTests
|
||||||
|
{
|
||||||
|
private static TraderFingerprintSnapshot Snap(
|
||||||
|
decimal score = 70m, decimal conc = 0.5m, decimal? conv = 5m,
|
||||||
|
decimal p50 = 100m, string? mix = null, string? traits = null, int daysAgo = 0)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
TraderId = 1,
|
||||||
|
CapturedAt = DateTime.UtcNow.AddDays(-daysAgo),
|
||||||
|
CopytradingScore = score,
|
||||||
|
CategoryConcentration = conc,
|
||||||
|
ConvictionEdgePct = conv,
|
||||||
|
P50PositionSize = p50,
|
||||||
|
CategoryMixJson = mix,
|
||||||
|
TraitSetJson = traits
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IdenticalFingerprints_DoNotDrift()
|
||||||
|
{
|
||||||
|
var b = Snap(mix: "{\"Sports\":1.0}", traits: "[\"scalper\"]", daysAgo: 14);
|
||||||
|
var c = Snap(mix: "{\"Sports\":1.0}", traits: "[\"scalper\"]");
|
||||||
|
var r = FingerprintDriftCalculator.Compare(b, c);
|
||||||
|
Assert.False(r.HasDrifted);
|
||||||
|
Assert.Empty(r.Dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScoreDrop_IsFlagged_ButScoreGainIsNot()
|
||||||
|
{
|
||||||
|
var dropped = FingerprintDriftCalculator.Compare(Snap(score: 70m, daysAgo: 14), Snap(score: 50m));
|
||||||
|
Assert.Contains(dropped.Dimensions, d => d.Dimension == "score");
|
||||||
|
|
||||||
|
var gained = FingerprintDriftCalculator.Compare(Snap(score: 50m, daysAgo: 14), Snap(score: 70m));
|
||||||
|
Assert.DoesNotContain(gained.Dimensions, d => d.Dimension == "score");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CategoryMixReversal_FlagsCategoryMix()
|
||||||
|
{
|
||||||
|
var b = Snap(mix: "{\"Sports\":1.0}", daysAgo: 14);
|
||||||
|
var c = Snap(mix: "{\"Politics\":1.0}");
|
||||||
|
var r = FingerprintDriftCalculator.Compare(b, c);
|
||||||
|
Assert.Contains(r.Dimensions, d => d.Dimension == "category_mix");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TraitSetChange_And_SizingJump_And_ConvictionFlip_AreFlagged()
|
||||||
|
{
|
||||||
|
var b = Snap(conv: 6m, p50: 100m, traits: "[\"scalper\"]", daysAgo: 14);
|
||||||
|
var c = Snap(conv: -4m, p50: 350m, traits: "[\"whale\",\"holds_to_resolution\"]");
|
||||||
|
var r = FingerprintDriftCalculator.Compare(b, c);
|
||||||
|
|
||||||
|
Assert.True(r.HasDrifted);
|
||||||
|
Assert.Contains(r.Dimensions, d => d.Dimension == "trait_set");
|
||||||
|
Assert.Contains(r.Dimensions, d => d.Dimension == "sizing");
|
||||||
|
Assert.Contains(r.Dimensions, d => d.Dimension == "conviction");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
using Predictalytics.Infrastructure.Services;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Tests.Services;
|
||||||
|
|
||||||
|
public class FingerprintSnapshotServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext CreateDbContext()
|
||||||
|
=> new(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
private static FingerprintSnapshotService CreateService(AppDbContext db)
|
||||||
|
=> new(db, NullLogger<FingerprintSnapshotService>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CaptureDueAsync_CapturesCopyRelevantOnly_AndIsIdempotentWithinWindow()
|
||||||
|
{
|
||||||
|
using var db = CreateDbContext();
|
||||||
|
|
||||||
|
var master = new Trader { Id = 1, PlatformUserId = "0xM", DisplayName = "Master",
|
||||||
|
Analytics = new TraderAnalytics { TraderId = 1, CopytradingScore = 60m, CategoryConcentration = 0.4m, P50PositionSize = 100m } };
|
||||||
|
master.Traits.Add(new TraderTrait { TraderId = 1, Trait = "holds_to_resolution", Value = 1m });
|
||||||
|
// Below the copy-relevance threshold -> must NOT be snapshotted.
|
||||||
|
var weak = new Trader { Id = 2, PlatformUserId = "0xW", DisplayName = "Weak",
|
||||||
|
Analytics = new TraderAnalytics { TraderId = 2, CopytradingScore = 20m } };
|
||||||
|
db.Traders.AddRange(master, weak);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = CreateService(db);
|
||||||
|
var first = await svc.CaptureDueAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, first);
|
||||||
|
Assert.Single(db.TraderFingerprintSnapshots);
|
||||||
|
Assert.Equal(1, db.TraderFingerprintSnapshots.Single().TraderId);
|
||||||
|
Assert.Contains("holds_to_resolution", db.TraderFingerprintSnapshots.Single().TraitSetJson);
|
||||||
|
|
||||||
|
// Second run within the throttle window captures nothing.
|
||||||
|
var second = await svc.CaptureDueAsync();
|
||||||
|
Assert.Equal(0, second);
|
||||||
|
Assert.Single(db.TraderFingerprintSnapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDriftAsync_NullWithoutBaseline_ThenDetectsScoreDrop()
|
||||||
|
{
|
||||||
|
using var db = CreateDbContext();
|
||||||
|
db.Traders.Add(new Trader { Id = 5, PlatformUserId = "0xD", DisplayName = "Drifter" });
|
||||||
|
// Only a recent snapshot -> no baseline yet.
|
||||||
|
db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
|
||||||
|
{
|
||||||
|
TraderId = 5, CapturedAt = DateTime.UtcNow, CopytradingScore = 45m
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = CreateService(db);
|
||||||
|
Assert.Null(await svc.GetDriftAsync(5, baselineDays: 14));
|
||||||
|
|
||||||
|
// Add an older baseline with a much higher score.
|
||||||
|
db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
|
||||||
|
{
|
||||||
|
TraderId = 5, CapturedAt = DateTime.UtcNow.AddDays(-20), CopytradingScore = 75m
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var drift = await svc.GetDriftAsync(5, baselineDays: 14);
|
||||||
|
Assert.NotNull(drift);
|
||||||
|
Assert.True(drift!.HasDrifted);
|
||||||
|
Assert.Contains(drift.Dimensions, d => d.Dimension == "score");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures periodic strategy-fingerprint snapshots (the history that drift detection #3 and
|
||||||
|
/// edge-freshness #5 build on) and reads drift off that history.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFingerprintSnapshotService
|
||||||
|
{
|
||||||
|
/// <summary>Capture a snapshot for every copy-relevant trader that has none in the last ~day. Returns the number captured.</summary>
|
||||||
|
Task<int> CaptureDueAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compare the trader's latest snapshot to the newest one at least <paramref name="baselineDays"/>
|
||||||
|
/// old. Returns null when there is not enough history to form a baseline.
|
||||||
|
/// </summary>
|
||||||
|
Task<FingerprintDriftResult?> GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A point-in-time capture of a trader's strategy fingerprint. Unlike <see cref="TraderAnalytics"/>
|
||||||
|
/// (one row per trader, overwritten every recalculation), these accumulate over time to form the
|
||||||
|
/// history that strategy-drift detection (#3) and edge-freshness (#5) compare against a baseline.
|
||||||
|
/// Captured roughly daily for copy-relevant traders.
|
||||||
|
/// </summary>
|
||||||
|
public class TraderFingerprintSnapshot
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of the capture.</summary>
|
||||||
|
public DateTime CapturedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// ── Fingerprint dimensions (mirrored from TraderAnalytics at capture time) ──
|
||||||
|
|
||||||
|
/// <summary>Copytrading suitability score (0-100) at capture time.</summary>
|
||||||
|
public decimal CopytradingScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Herfindahl index of category volume shares (0..1; 1 = single-category specialist).</summary>
|
||||||
|
public decimal CategoryConcentration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Conviction edge: return% of the biggest-bet third minus the smallest-bet third. Null when undefined.</summary>
|
||||||
|
public decimal? ConvictionEdgePct { get; set; }
|
||||||
|
|
||||||
|
public decimal P50PositionSize { get; set; }
|
||||||
|
public decimal P90PositionSize { get; set; }
|
||||||
|
public decimal MedianHoldDurationHours { get; set; }
|
||||||
|
public decimal TradesPerWeek { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JSON map of category => volume share (0..1) at capture time, for category-mix drift.</summary>
|
||||||
|
public string? CategoryMixJson { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JSON array of the trait keys present at capture time, for trait-set drift.</summary>
|
||||||
|
public string? TraitSetJson { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Trader Trader { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
|
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
|
||||||
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
|
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
|
||||||
public DbSet<InsiderWatch> InsiderWatches => Set<InsiderWatch>();
|
public DbSet<InsiderWatch> InsiderWatches => Set<InsiderWatch>();
|
||||||
|
public DbSet<TraderFingerprintSnapshot> TraderFingerprintSnapshots => Set<TraderFingerprintSnapshot>();
|
||||||
|
|
||||||
private readonly bool _isReadOnly;
|
private readonly bool _isReadOnly;
|
||||||
|
|
||||||
@@ -216,6 +217,14 @@ public class AppDbContext : DbContext
|
|||||||
e.HasOne(i => i.Trader).WithMany().HasForeignKey(i => i.TraderId);
|
e.HasOne(i => i.Trader).WithMany().HasForeignKey(i => i.TraderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TraderFingerprintSnapshot (time-series; many rows per trader)
|
||||||
|
mb.Entity<TraderFingerprintSnapshot>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(s => s.Id);
|
||||||
|
e.HasIndex(s => new { s.TraderId, s.CapturedAt });
|
||||||
|
e.HasOne(s => s.Trader).WithMany().HasForeignKey(s => s.TraderId);
|
||||||
|
});
|
||||||
|
|
||||||
// Alert
|
// Alert
|
||||||
mb.Entity<Alert>(e =>
|
mb.Entity<Alert>(e =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
||||||
services.AddScoped<IAlertService, AlertService>();
|
services.AddScoped<IAlertService, AlertService>();
|
||||||
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||||
|
services.AddScoped<IFingerprintSnapshotService, FingerprintSnapshotService>();
|
||||||
services.AddScoped<ICopytradingEstimator, CopytradingEstimator>();
|
services.AddScoped<ICopytradingEstimator, CopytradingEstimator>();
|
||||||
services.AddScoped<ICopytradingBacktestHarness, CopytradingBacktestHarness>();
|
services.AddScoped<ICopytradingBacktestHarness, CopytradingBacktestHarness>();
|
||||||
services.AddScoped<WatchlistService>();
|
services.AddScoped<WatchlistService>();
|
||||||
|
|||||||
Generated
+1385
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFingerprintSnapshots : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TraderFingerprintSnapshots",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CapturedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CopytradingScore = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CategoryConcentration = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
ConvictionEdgePct = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
|
||||||
|
P50PositionSize = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
P90PositionSize = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
MedianHoldDurationHours = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
TradesPerWeek = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CategoryMixJson = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
TraitSetJson = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TraderFingerprintSnapshots", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TraderFingerprintSnapshots_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TraderFingerprintSnapshots_TraderId_CapturedAt",
|
||||||
|
table: "TraderFingerprintSnapshots",
|
||||||
|
columns: new[] { "TraderId", "CapturedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TraderFingerprintSnapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -869,6 +869,54 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.ToTable("TraderDailySnapshots");
|
b.ToTable("TraderDailySnapshots");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderFingerprintSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CapturedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CategoryConcentration")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryMixJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<decimal?>("ConvictionEdgePct")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CopytradingScore")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("MedianHoldDurationHours")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("P50PositionSize")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("P90PositionSize")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TradesPerWeek")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<string>("TraitSetJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId", "CapturedAt");
|
||||||
|
|
||||||
|
b.ToTable("TraderFingerprintSnapshots");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1221,6 +1269,17 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.Navigation("Trader");
|
b.Navigation("Trader");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderFingerprintSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Application.Services;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the strategy-fingerprint time series (<see cref="TraderFingerprintSnapshot"/>) from the
|
||||||
|
/// current persisted analytics, and reads drift off that history. Capture is scoped to copy-relevant
|
||||||
|
/// traders and rate-limited to about one row per trader per day so the table stays bounded.
|
||||||
|
/// </summary>
|
||||||
|
public class FingerprintSnapshotService : IFingerprintSnapshotService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<FingerprintSnapshotService> _logger;
|
||||||
|
|
||||||
|
/// <summary>Only snapshot traders worth monitoring for drift (i.e. plausible copy candidates).</summary>
|
||||||
|
private const decimal MinCopytradingScore = 40m;
|
||||||
|
|
||||||
|
/// <summary>Don't capture more than one snapshot per trader within this window.</summary>
|
||||||
|
private static readonly TimeSpan MinCaptureInterval = TimeSpan.FromHours(20);
|
||||||
|
|
||||||
|
public FingerprintSnapshotService(AppDbContext db, ILogger<FingerprintSnapshotService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CaptureDueAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var cutoff = now - MinCaptureInterval;
|
||||||
|
|
||||||
|
// Traders already snapshotted within the window are skipped.
|
||||||
|
var recentlyCaptured = await _db.TraderFingerprintSnapshots
|
||||||
|
.Where(s => s.CapturedAt >= cutoff)
|
||||||
|
.Select(s => s.TraderId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var skip = recentlyCaptured.ToHashSet();
|
||||||
|
|
||||||
|
var candidates = await _db.Traders
|
||||||
|
.Include(t => t.Analytics)
|
||||||
|
.Include(t => t.Traits)
|
||||||
|
.Include(t => t.CategoryPerformances)
|
||||||
|
.Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= MinCopytradingScore)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
int captured = 0;
|
||||||
|
foreach (var trader in candidates)
|
||||||
|
{
|
||||||
|
if (skip.Contains(trader.Id)) continue;
|
||||||
|
var a = trader.Analytics!;
|
||||||
|
|
||||||
|
_db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
|
||||||
|
{
|
||||||
|
TraderId = trader.Id,
|
||||||
|
CapturedAt = now,
|
||||||
|
CopytradingScore = a.CopytradingScore,
|
||||||
|
CategoryConcentration = a.CategoryConcentration,
|
||||||
|
ConvictionEdgePct = a.ConvictionEdgePct,
|
||||||
|
P50PositionSize = a.P50PositionSize,
|
||||||
|
P90PositionSize = a.P90PositionSize,
|
||||||
|
MedianHoldDurationHours = a.MedianHoldDurationHours,
|
||||||
|
TradesPerWeek = a.TradesPerWeek,
|
||||||
|
CategoryMixJson = BuildCategoryMixJson(trader),
|
||||||
|
TraitSetJson = JsonSerializer.Serialize(trader.Traits.Select(tr => tr.Trait).OrderBy(x => x).ToList())
|
||||||
|
});
|
||||||
|
captured++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (captured > 0)
|
||||||
|
{
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_logger.LogInformation("📸 Fingerprint snapshots captured for {Count} traders", captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FingerprintDriftResult?> GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var latest = await _db.TraderFingerprintSnapshots
|
||||||
|
.Where(s => s.TraderId == traderId)
|
||||||
|
.OrderByDescending(s => s.CapturedAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (latest == null) return null;
|
||||||
|
|
||||||
|
var baselineCutoff = latest.CapturedAt.AddDays(-baselineDays);
|
||||||
|
var baseline = await _db.TraderFingerprintSnapshots
|
||||||
|
.Where(s => s.TraderId == traderId && s.CapturedAt <= baselineCutoff)
|
||||||
|
.OrderByDescending(s => s.CapturedAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (baseline == null || baseline.Id == latest.Id) return null;
|
||||||
|
|
||||||
|
return FingerprintDriftCalculator.Compare(baseline, latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildCategoryMixJson(Trader trader)
|
||||||
|
{
|
||||||
|
var perfs = trader.CategoryPerformances;
|
||||||
|
if (perfs == null || perfs.Count == 0) return null;
|
||||||
|
|
||||||
|
var total = perfs.Sum(p => p.TotalVolume);
|
||||||
|
if (total <= 0) return null;
|
||||||
|
|
||||||
|
var mix = perfs.ToDictionary(p => p.Category.ToString(), p => p.TotalVolume / total);
|
||||||
|
return JsonSerializer.Serialize(mix);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,9 +39,12 @@ public class ScoringAndAlertsWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
|
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
|
||||||
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||||
|
var fingerprintSnapshots = scope.ServiceProvider.GetRequiredService<IFingerprintSnapshotService>();
|
||||||
|
|
||||||
await scoringService.RecalculateAllScoresAsync(stoppingToken);
|
await scoringService.RecalculateAllScoresAsync(stoppingToken);
|
||||||
await alertService.EvaluateAlertsAsync(stoppingToken);
|
await alertService.EvaluateAlertsAsync(stoppingToken);
|
||||||
|
// Capture the strategy-fingerprint time series (self-throttled to ~1/trader/day).
|
||||||
|
await fingerprintSnapshots.CaptureDueAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("📈 ScoringAndAlertsWorker: Recalculation cycle complete.");
|
_logger.LogInformation("📈 ScoringAndAlertsWorker: Recalculation cycle complete.");
|
||||||
|
|||||||
Reference in New Issue
Block a user