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