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,
|
int LongestLosingStreakDays,
|
||||||
decimal? ReturnOverMaxDrawdown,
|
decimal? ReturnOverMaxDrawdown,
|
||||||
|
|
||||||
|
// H2 Strategy fingerprint
|
||||||
|
decimal CategoryConcentration,
|
||||||
|
decimal? ConvictionEdgePct,
|
||||||
|
|
||||||
int Rank,
|
int Rank,
|
||||||
bool IsOnWatchlist,
|
bool IsOnWatchlist,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ public class AnalyticsService : IAnalyticsService
|
|||||||
s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0,
|
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?.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?.MaxDrawdownUsd ?? 0, a?.PnlVolatilityUsd ?? 0, a?.LongestLosingStreakDays ?? 0, a?.ReturnOverMaxDrawdown,
|
||||||
|
a?.CategoryConcentration ?? 0, a?.ConvictionEdgePct,
|
||||||
s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
|
s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
|
||||||
trader.AiStrategySummary,
|
trader.AiStrategySummary,
|
||||||
trades.Select(MapTradeDto).ToList(),
|
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 decimal PnlVolatilityUsd { get; set; }
|
||||||
public int LongestLosingStreakDays { 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>
|
/// <summary>Calmar-like: profit per unit of worst drawdown. Null when there was no drawdown.</summary>
|
||||||
public decimal? ReturnOverMaxDrawdown =>
|
public decimal? ReturnOverMaxDrawdown =>
|
||||||
MaxDrawdownUsd > 0 ? System.Math.Round(OverallPnL / MaxDrawdownUsd, 2) : null;
|
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)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("decimal(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")
|
b.Property<decimal>("CopytradingCopyabilityScore")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
|||||||
@@ -376,20 +376,26 @@ public class PositionPnLEngine : IPositionPnLEngine
|
|||||||
analytics.LongestLosingStreakDays = risk.LongestLosingStreakDays;
|
analytics.LongestLosingStreakDays = risk.LongestLosingStreakDays;
|
||||||
|
|
||||||
// Calculate Win Rate and Return Pcts on Market level
|
// Calculate Win Rate and Return Pcts on Market level
|
||||||
var (winRateOverall, winRate30d, winRate7d, winRate24h,
|
var (winRateOverall, winRate30d, winRate7d, winRate24h,
|
||||||
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
|
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
|
||||||
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
|
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h,
|
||||||
|
out var closedMarketReturns);
|
||||||
|
|
||||||
analytics.OverallWinRate = winRateOverall;
|
analytics.OverallWinRate = winRateOverall;
|
||||||
analytics.WinRate30d = winRate30d;
|
analytics.WinRate30d = winRate30d;
|
||||||
analytics.WinRate7d = winRate7d;
|
analytics.WinRate7d = winRate7d;
|
||||||
analytics.WinRate24h = winRate24h;
|
analytics.WinRate24h = winRate24h;
|
||||||
|
|
||||||
analytics.MedianWinReturnPct = medianWin;
|
analytics.MedianWinReturnPct = medianWin;
|
||||||
analytics.AvgWinReturnPct = avgWin;
|
analytics.AvgWinReturnPct = avgWin;
|
||||||
analytics.MedianLossReturnPct = medianLoss;
|
analytics.MedianLossReturnPct = medianLoss;
|
||||||
analytics.AvgLossReturnPct = avgLoss;
|
analytics.AvgLossReturnPct = avgLoss;
|
||||||
analytics.ProfitFactor = profitFactor;
|
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;
|
analytics.LastCalculatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
// Sync back to Trader record for quick sorting / UI display
|
// 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);
|
.ToDictionaryAsync(tcp => (tcp.Category, tcp.Subcategory), ct);
|
||||||
|
|
||||||
var newCatPerf = CalculateCategoryPerformances(trades, tempPositions);
|
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)
|
foreach (var kvp in newCatPerf)
|
||||||
{
|
{
|
||||||
if (existingCatPerf.TryGetValue(kvp.Key, out var existing))
|
if (existingCatPerf.TryGetValue(kvp.Key, out var existing))
|
||||||
@@ -446,8 +457,10 @@ public class PositionPnLEngine : IPositionPnLEngine
|
|||||||
Dictionary<int, TraderPosition> finalPositions,
|
Dictionary<int, TraderPosition> finalPositions,
|
||||||
DateTime cutoff30d,
|
DateTime cutoff30d,
|
||||||
DateTime cutoff7d,
|
DateTime cutoff7d,
|
||||||
DateTime cutoff24h)
|
DateTime cutoff24h,
|
||||||
|
out List<(decimal Invested, decimal ReturnPct)> closedMarketReturns)
|
||||||
{
|
{
|
||||||
|
closedMarketReturns = new List<(decimal, decimal)>();
|
||||||
// Group trades by Market
|
// Group trades by Market
|
||||||
var tradesByMarket = trades
|
var tradesByMarket = trades
|
||||||
.Where(t => t.DbMarketId.HasValue || !string.IsNullOrEmpty(t.MarketId))
|
.Where(t => t.DbMarketId.HasValue || !string.IsNullOrEmpty(t.MarketId))
|
||||||
@@ -496,12 +509,13 @@ public class PositionPnLEngine : IPositionPnLEngine
|
|||||||
if (invested > 0)
|
if (invested > 0)
|
||||||
{
|
{
|
||||||
var returnPct = marketPnl / invested * 100m;
|
var returnPct = marketPnl / invested * 100m;
|
||||||
if (returnPct > 0)
|
closedMarketReturns.Add((invested, returnPct));
|
||||||
|
if (returnPct > 0)
|
||||||
{
|
{
|
||||||
winReturns.Add(returnPct);
|
winReturns.Add(returnPct);
|
||||||
totalGrossWins += marketPnl;
|
totalGrossWins += marketPnl;
|
||||||
}
|
}
|
||||||
else if (returnPct < 0)
|
else if (returnPct < 0)
|
||||||
{
|
{
|
||||||
lossReturns.Add(returnPct);
|
lossReturns.Add(returnPct);
|
||||||
totalGrossLosses += Math.Abs(marketPnl);
|
totalGrossLosses += Math.Abs(marketPnl);
|
||||||
|
|||||||
Reference in New Issue
Block a user