H2: strategy-fingerprint metrics (conviction/sizing edge + category concentration)

Two deeper analyses, both pure from existing data (no new API cost):
- ConvictionEdgePct: return% of the biggest-bet third minus the smallest-bet
  third of closed markets. Positive => sizing carries information (copy
  size-weighted); negative => overbets losers (red flag). CalculateMarketWinRates
  now emits per-market (invested, returnPct) pairs consumed by
  StrategyMetricsCalculator.ComputeConvictionEdge.
- CategoryConcentration: Herfindahl index of category volume shares
  (specialist vs generalist), from the category-performance dict.
Stored on TraderAnalytics, exposed on TraderDetailDto. Migration
AddStrategyFingerprintMetrics. +7 unit tests (70 total, 1 skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-23 09:17:58 +02:00
co-authored by Claude Opus 4.8
parent dcac62165e
commit eaccdadddf
9 changed files with 1493 additions and 8 deletions
@@ -0,0 +1,74 @@
using System.Collections.Generic;
using Predictalytics.Application.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class StrategyMetricsCalculatorTests
{
// ── Concentration (Herfindahl) ────────────────────────────────────────────
[Fact]
public void Concentration_SingleCategory_IsOne()
{
Assert.Equal(1.0m, StrategyMetricsCalculator.ComputeConcentration(new[] { 500m }));
}
[Fact]
public void Concentration_TwoEqualCategories_IsHalf()
{
// shares 0.5, 0.5 -> 0.25 + 0.25 = 0.5
Assert.Equal(0.5m, StrategyMetricsCalculator.ComputeConcentration(new[] { 100m, 100m }));
}
[Fact]
public void Concentration_SpecialistScoresHigherThanGeneralist()
{
var specialist = StrategyMetricsCalculator.ComputeConcentration(new[] { 900m, 50m, 50m });
var generalist = StrategyMetricsCalculator.ComputeConcentration(new[] { 100m, 100m, 100m, 100m });
Assert.True(specialist > generalist);
}
[Fact]
public void Concentration_NoVolume_IsZero()
{
Assert.Equal(0m, StrategyMetricsCalculator.ComputeConcentration(new[] { 0m, 0m }));
}
// ── Conviction / sizing edge ──────────────────────────────────────────────
[Fact]
public void Conviction_TooFewMarkets_IsNull()
{
var markets = new List<(decimal, decimal)> { (10m, 5m), (20m, 5m), (30m, 5m) };
Assert.Null(StrategyMetricsCalculator.ComputeConvictionEdge(markets));
}
[Fact]
public void Conviction_BigBetsWinMore_IsPositive()
{
// small bets (invested 10-30) return ~0%, big bets (invested 100-120) return ~+40%
var markets = new List<(decimal Invested, decimal ReturnPct)>
{
(10m, 0m), (20m, 2m), (30m, -2m),
(50m, 5m), (60m, 3m), (70m, 4m),
(100m, 40m), (110m, 38m), (120m, 42m),
};
var edge = StrategyMetricsCalculator.ComputeConvictionEdge(markets);
Assert.NotNull(edge);
Assert.True(edge > 30m); // big third avg ~40 minus small third avg ~0
}
[Fact]
public void Conviction_OverbetsLosers_IsNegative()
{
// big bets LOSE, small bets win -> negative conviction (a red flag)
var markets = new List<(decimal Invested, decimal ReturnPct)>
{
(10m, 20m), (20m, 25m), (30m, 22m),
(50m, 5m), (60m, 3m), (70m, 4m),
(100m, -30m), (110m, -35m), (120m, -28m),
};
var edge = StrategyMetricsCalculator.ComputeConvictionEdge(markets);
Assert.NotNull(edge);
Assert.True(edge < 0m);
}
}
@@ -70,6 +70,10 @@ public record TraderDetailDto(
int LongestLosingStreakDays,
decimal? ReturnOverMaxDrawdown,
// H2 Strategy fingerprint
decimal CategoryConcentration,
decimal? ConvictionEdgePct,
int Rank,
bool IsOnWatchlist,
DateTime CreatedAt,
@@ -256,6 +256,7 @@ public class AnalyticsService : IAnalyticsService
s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0,
a?.MedianWinReturnPct ?? 0, a?.AvgWinReturnPct ?? 0, a?.MedianLossReturnPct ?? 0, a?.AvgLossReturnPct ?? 0, a?.ProfitFactor,
a?.MaxDrawdownUsd ?? 0, a?.PnlVolatilityUsd ?? 0, a?.LongestLosingStreakDays ?? 0, a?.ReturnOverMaxDrawdown,
a?.CategoryConcentration ?? 0, a?.ConvictionEdgePct,
s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList(),
@@ -0,0 +1,51 @@
namespace Predictalytics.Application.Services;
/// <summary>
/// Deeper strategy-fingerprint metrics computed purely from a trader's closed markets and
/// category mix — no new data collection required.
/// </summary>
public static class StrategyMetricsCalculator
{
/// <summary>
/// Category concentration via the HerfindahlHirschman Index over volume shares.
/// 1.0 = everything in a single category (specialist); approaches 1/n for an even spread
/// (generalist). Returns 0 when there is no volume. A specialist's edge is often more
/// trustworthy inside their niche and more suspect outside it.
/// </summary>
public static decimal ComputeConcentration(IEnumerable<decimal> categoryVolumes)
{
var vols = categoryVolumes.Where(v => v > 0).ToList();
var total = vols.Sum();
if (total <= 0) return 0m;
decimal hhi = 0m;
foreach (var v in vols)
{
var share = v / total;
hhi += share * share;
}
return System.Math.Round(hhi, 4);
}
/// <summary>
/// Conviction / sizing edge: do the trader's BIGGEST bets outperform their smallest?
/// Splits closed markets into the top and bottom third by invested capital and returns
/// (avg return% of the big-bet third) (avg return% of the small-bet third).
/// Positive ⇒ their sizing carries information (bigger conviction → better outcome), so a
/// copier should size-weight them; ≈0 ⇒ size is noise, copy flat; negative ⇒ they overbet
/// their losers (a red flag). Needs ≥ 6 closed markets; returns null otherwise.
/// </summary>
public static decimal? ComputeConvictionEdge(IReadOnlyList<(decimal Invested, decimal ReturnPct)> closedMarkets)
{
var valid = closedMarkets.Where(m => m.Invested > 0).OrderBy(m => m.Invested).ToList();
if (valid.Count < 6) return null;
int third = valid.Count / 3;
var smallBets = valid.Take(third).ToList();
var bigBets = valid.Skip(valid.Count - third).ToList();
var smallAvg = smallBets.Average(m => m.ReturnPct);
var bigAvg = bigBets.Average(m => m.ReturnPct);
return System.Math.Round(bigAvg - smallAvg, 2);
}
}
@@ -60,6 +60,12 @@ public class TraderAnalytics
public decimal PnlVolatilityUsd { get; set; }
public int LongestLosingStreakDays { get; set; }
// H2 Strategy fingerprint
/// <summary>Herfindahl index of category volume shares (0..1; 1 = single-category specialist).</summary>
public decimal CategoryConcentration { get; set; }
/// <summary>Return% of the biggest-bet third minus the smallest-bet third. Null = too few closed markets.</summary>
public decimal? ConvictionEdgePct { get; set; }
/// <summary>Calmar-like: profit per unit of worst drawdown. Null when there was no drawdown.</summary>
public decimal? ReturnOverMaxDrawdown =>
MaxDrawdownUsd > 0 ? System.Math.Round(OverallPnL / MaxDrawdownUsd, 2) : null;
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddStrategyFingerprintMetrics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CategoryConcentration",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "ConvictionEdgePct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CategoryConcentration",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "ConvictionEdgePct",
table: "TraderAnalytics");
}
}
}
@@ -652,6 +652,12 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("CategoryConcentration")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("ConvictionEdgePct")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingCopyabilityScore")
.HasColumnType("decimal(65,30)");
@@ -378,7 +378,8 @@ public class PositionPnLEngine : IPositionPnLEngine
// Calculate Win Rate and Return Pcts on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h,
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h,
out var closedMarketReturns);
analytics.OverallWinRate = winRateOverall;
analytics.WinRate30d = winRate30d;
@@ -390,6 +391,11 @@ public class PositionPnLEngine : IPositionPnLEngine
analytics.MedianLossReturnPct = medianLoss;
analytics.AvgLossReturnPct = avgLoss;
analytics.ProfitFactor = profitFactor;
// H2: conviction/sizing edge — do the biggest bets outperform the smallest?
analytics.ConvictionEdgePct =
Predictalytics.Application.Services.StrategyMetricsCalculator.ComputeConvictionEdge(closedMarketReturns);
analytics.LastCalculatedAt = DateTime.UtcNow;
// Sync back to Trader record for quick sorting / UI display
@@ -412,6 +418,11 @@ public class PositionPnLEngine : IPositionPnLEngine
var newCatPerf = CalculateCategoryPerformances(trades, tempPositions);
// H2: how concentrated is the trader across categories (specialist vs generalist)?
analytics.CategoryConcentration =
Predictalytics.Application.Services.StrategyMetricsCalculator.ComputeConcentration(
newCatPerf.Values.Select(p => p.TotalVolume));
foreach (var kvp in newCatPerf)
{
if (existingCatPerf.TryGetValue(kvp.Key, out var existing))
@@ -446,8 +457,10 @@ public class PositionPnLEngine : IPositionPnLEngine
Dictionary<int, TraderPosition> finalPositions,
DateTime cutoff30d,
DateTime cutoff7d,
DateTime cutoff24h)
DateTime cutoff24h,
out List<(decimal Invested, decimal ReturnPct)> closedMarketReturns)
{
closedMarketReturns = new List<(decimal, decimal)>();
// Group trades by Market
var tradesByMarket = trades
.Where(t => t.DbMarketId.HasValue || !string.IsNullOrEmpty(t.MarketId))
@@ -496,6 +509,7 @@ public class PositionPnLEngine : IPositionPnLEngine
if (invested > 0)
{
var returnPct = marketPnl / invested * 100m;
closedMarketReturns.Add((invested, returnPct));
if (returnPct > 0)
{
winReturns.Add(returnPct);