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:
co-authored by
Claude Opus 4.8
parent
dcac62165e
commit
eaccdadddf
@@ -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 Herfindahl–Hirschman 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;
|
||||
|
||||
+1290
File diff suppressed because it is too large
Load Diff
+39
@@ -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)");
|
||||
|
||||
|
||||
@@ -376,20 +376,26 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
analytics.LongestLosingStreakDays = risk.LongestLosingStreakDays;
|
||||
|
||||
// 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);
|
||||
var (winRateOverall, winRate30d, winRate7d, winRate24h,
|
||||
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
|
||||
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h,
|
||||
out var closedMarketReturns);
|
||||
|
||||
analytics.OverallWinRate = winRateOverall;
|
||||
analytics.WinRate30d = winRate30d;
|
||||
analytics.WinRate7d = winRate7d;
|
||||
analytics.WinRate24h = winRate24h;
|
||||
|
||||
|
||||
analytics.MedianWinReturnPct = medianWin;
|
||||
analytics.AvgWinReturnPct = avgWin;
|
||||
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
|
||||
@@ -411,7 +417,12 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
.ToDictionaryAsync(tcp => (tcp.Category, tcp.Subcategory), ct);
|
||||
|
||||
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,12 +509,13 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
if (invested > 0)
|
||||
{
|
||||
var returnPct = marketPnl / invested * 100m;
|
||||
if (returnPct > 0)
|
||||
closedMarketReturns.Add((invested, returnPct));
|
||||
if (returnPct > 0)
|
||||
{
|
||||
winReturns.Add(returnPct);
|
||||
totalGrossWins += marketPnl;
|
||||
}
|
||||
else if (returnPct < 0)
|
||||
else if (returnPct < 0)
|
||||
{
|
||||
lossReturns.Add(returnPct);
|
||||
totalGrossLosses += Math.Abs(marketPnl);
|
||||
|
||||
Reference in New Issue
Block a user