H1: risk-adjusted return metrics (equity-curve smoothness = copyability)

New RiskMetricsCalculator (pure) derives max drawdown (USD), daily-PnL volatility
and longest losing streak from the trader's TraderDailySnapshot equity curve; the
PnL engine computes them each recalc and stores them on TraderAnalytics (+ computed
ReturnOverMaxDrawdown, Calmar-like). Two traders with identical final PnL but a
smoother path are very differently copyable — this captures that. Exposed on
TraderDetailDto. Migration AddRiskAdjustedMetrics. +5 unit tests (63 total, 1 skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-20 09:14:15 +02:00
co-authored by Claude Opus 4.8
parent be1b90b556
commit dcac62165e
9 changed files with 1502 additions and 1 deletions
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using Predictalytics.Application.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class RiskMetricsCalculatorTests
{
private static List<(DateTime, decimal)> Curve(params decimal[] pnls)
{
var list = new List<(DateTime, decimal)>();
var d = new DateTime(2026, 01, 01, 0, 0, 0, DateTimeKind.Utc);
foreach (var p in pnls) { list.Add((d, p)); d = d.AddDays(1); }
return list;
}
[Fact]
public void Empty_ReturnsZeros()
{
var m = RiskMetricsCalculator.Compute(new List<(DateTime, decimal)>());
Assert.Equal(0m, m.MaxDrawdownUsd);
Assert.Equal(0m, m.PnlVolatilityUsd);
Assert.Equal(0, m.LongestLosingStreakDays);
}
[Fact]
public void MonotonicUp_HasNoDrawdownNoStreak()
{
// Steady climb 0 -> 10 -> 20 -> 30: the ideal, most-copyable curve.
var m = RiskMetricsCalculator.Compute(Curve(0, 10, 20, 30));
Assert.Equal(0m, m.MaxDrawdownUsd);
Assert.Equal(0, m.LongestLosingStreakDays);
}
[Fact]
public void DipFromPeak_MeasuresPeakToTroughDrawdown()
{
// Peak 100, trough 40 -> max drawdown 60. Ends back at 90.
var m = RiskMetricsCalculator.Compute(Curve(0, 100, 70, 40, 90));
Assert.Equal(60m, m.MaxDrawdownUsd);
}
[Fact]
public void LongestLosingStreak_CountsConsecutiveDownDays()
{
// deltas: +100, -30, -30, -30, +50 -> 3 consecutive losing days
var m = RiskMetricsCalculator.Compute(Curve(0, 100, 70, 40, 10, 60));
Assert.Equal(3, m.LongestLosingStreakDays);
}
[Fact]
public void Volatility_IsZeroForConstantDailyGain_PositiveForBumpyPath()
{
// Constant +10/day -> zero volatility of daily returns.
var smooth = RiskMetricsCalculator.Compute(Curve(0, 10, 20, 30, 40));
Assert.Equal(0m, smooth.PnlVolatilityUsd);
// Same endpoint (+40) but a bumpy path -> positive volatility.
var bumpy = RiskMetricsCalculator.Compute(Curve(0, 50, 10, 60, 40));
Assert.True(bumpy.PnlVolatilityUsd > 0m);
}
}
@@ -63,7 +63,13 @@ public record TraderDetailDto(
decimal MedianLossReturnPct,
decimal AvgLossReturnPct,
decimal? ProfitFactor,
// H1 Risk-adjusted return (equity-curve smoothness = copyability)
decimal MaxDrawdownUsd,
decimal PnlVolatilityUsd,
int LongestLosingStreakDays,
decimal? ReturnOverMaxDrawdown,
int Rank,
bool IsOnWatchlist,
DateTime CreatedAt,
@@ -255,6 +255,7 @@ public class AnalyticsService : IAnalyticsService
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 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?.MaxDrawdownUsd ?? 0, a?.PnlVolatilityUsd ?? 0, a?.LongestLosingStreakDays ?? 0, a?.ReturnOverMaxDrawdown,
s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList(),
@@ -0,0 +1,64 @@
namespace Predictalytics.Application.Services;
/// <summary>Risk/smoothness metrics derived from a trader's daily equity curve.</summary>
public readonly record struct RiskMetrics(
decimal MaxDrawdownUsd,
decimal PnlVolatilityUsd,
int LongestLosingStreakDays);
/// <summary>
/// Computes how SMOOTH a trader's path to their PnL was — a core copyability signal.
/// Two traders with identical final PnL are very differently copyable if one got there
/// steadily and the other via a violent up-and-down ride: the copier who joins mid-drawdown
/// of the volatile trader may never recover. Pure function, no DB/API access.
/// </summary>
public static class RiskMetricsCalculator
{
/// <param name="snapshotsAsc">Daily cumulative PnL points, ascending by date.</param>
public static RiskMetrics Compute(IReadOnlyList<(DateTime Date, decimal TotalPnl)> snapshotsAsc)
{
if (snapshotsAsc is null || snapshotsAsc.Count == 0)
return new RiskMetrics(0m, 0m, 0);
// Max drawdown: largest peak-to-trough drop of the cumulative-PnL curve (in USD).
decimal peak = snapshotsAsc[0].TotalPnl;
decimal maxDrawdown = 0m;
foreach (var s in snapshotsAsc)
{
if (s.TotalPnl > peak) peak = s.TotalPnl;
var dd = peak - s.TotalPnl;
if (dd > maxDrawdown) maxDrawdown = dd;
}
// Day-over-day PnL deltas -> volatility (population stddev) + longest losing streak.
var deltas = new List<decimal>(snapshotsAsc.Count);
int streak = 0, longestStreak = 0;
for (int i = 1; i < snapshotsAsc.Count; i++)
{
var d = snapshotsAsc[i].TotalPnl - snapshotsAsc[i - 1].TotalPnl;
deltas.Add(d);
if (d < 0)
{
streak++;
if (streak > longestStreak) longestStreak = streak;
}
else
{
streak = 0;
}
}
decimal volatility = 0m;
if (deltas.Count > 0)
{
var mean = deltas.Average();
var variance = deltas.Sum(x => (x - mean) * (x - mean)) / deltas.Count;
volatility = (decimal)System.Math.Sqrt((double)variance);
}
return new RiskMetrics(
System.Math.Round(maxDrawdown, 2),
System.Math.Round(volatility, 2),
longestStreak);
}
}
@@ -55,6 +55,15 @@ public class TraderAnalytics
public decimal MedianPostFillDriftPct { get; set; }
public decimal NetEdgeAfterFeesPct { get; set; }
// H1 Risk-adjusted return (from the daily equity curve; smoothness = copyability)
public decimal MaxDrawdownUsd { get; set; }
public decimal PnlVolatilityUsd { get; set; }
public int LongestLosingStreakDays { 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;
// Navigation
public virtual Trader Trader { get; set; } = null!;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRiskAdjustedMetrics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LongestLosingStreakDays",
table: "TraderAnalytics",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<decimal>(
name: "MaxDrawdownUsd",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PnlVolatilityUsd",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LongestLosingStreakDays",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "MaxDrawdownUsd",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "PnlVolatilityUsd",
table: "TraderAnalytics");
}
}
}
@@ -670,6 +670,12 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("LongestLosingStreakDays")
.HasColumnType("int");
b.Property<decimal>("MaxDrawdownUsd")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("MedianHoldDurationHours")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
@@ -722,6 +728,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnlVolatilityUsd")
.HasColumnType("decimal(65,30)");
b.Property<string>("PriceBandProfileJson")
.HasColumnType("longtext");
@@ -361,6 +361,20 @@ public class PositionPnLEngine : IPositionPnLEngine
// Count Trades30d
analytics.Trades30d = trades.Where(t => t.ExecutedAt >= cutoff30d).Sum(t => t.AggregatedCount ?? 1);
// H1: Risk-adjusted return from the daily equity curve (smoothness = copyability).
// Build the series from persisted history + today's freshly computed point.
var historicalSnapshots = await _db.TraderDailySnapshots
.Where(s => s.TraderId == traderId && s.Date < today)
.OrderBy(s => s.Date)
.Select(s => new { s.Date, s.TotalPnl })
.ToListAsync(ct);
var equitySeries = historicalSnapshots.Select(h => (h.Date, h.TotalPnl)).ToList();
equitySeries.Add((today, overallPnl));
var risk = Predictalytics.Application.Services.RiskMetricsCalculator.Compute(equitySeries);
analytics.MaxDrawdownUsd = risk.MaxDrawdownUsd;
analytics.PnlVolatilityUsd = risk.PnlVolatilityUsd;
analytics.LongestLosingStreakDays = risk.LongestLosingStreakDays;
// Calculate Win Rate and Return Pcts on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h,
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =