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