@
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);
|
||||
});
|
||||
|
||||
// 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) =>
|
||||
{
|
||||
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<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
|
||||
public DbSet<InsiderWatch> InsiderWatches => Set<InsiderWatch>();
|
||||
public DbSet<TraderFingerprintSnapshot> TraderFingerprintSnapshots => Set<TraderFingerprintSnapshot>();
|
||||
|
||||
private readonly bool _isReadOnly;
|
||||
|
||||
@@ -216,6 +217,14 @@ public class AppDbContext : DbContext
|
||||
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
|
||||
mb.Entity<Alert>(e =>
|
||||
{
|
||||
|
||||
@@ -76,6 +76,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
||||
services.AddScoped<IAlertService, AlertService>();
|
||||
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
services.AddScoped<IFingerprintSnapshotService, FingerprintSnapshotService>();
|
||||
services.AddScoped<ICopytradingEstimator, CopytradingEstimator>();
|
||||
services.AddScoped<ICopytradingBacktestHarness, CopytradingBacktestHarness>();
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1221,6 +1269,17 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
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 alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
var fingerprintSnapshots = scope.ServiceProvider.GetRequiredService<IFingerprintSnapshotService>();
|
||||
|
||||
await scoringService.RecalculateAllScoresAsync(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.");
|
||||
|
||||
Reference in New Issue
Block a user