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:
co-authored by
Claude Opus 4.8
parent
be1b90b556
commit
dcac62165e
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,12 @@ public record TraderDetailDto(
|
|||||||
decimal AvgLossReturnPct,
|
decimal AvgLossReturnPct,
|
||||||
decimal? ProfitFactor,
|
decimal? ProfitFactor,
|
||||||
|
|
||||||
|
// H1 Risk-adjusted return (equity-curve smoothness = copyability)
|
||||||
|
decimal MaxDrawdownUsd,
|
||||||
|
decimal PnlVolatilityUsd,
|
||||||
|
int LongestLosingStreakDays,
|
||||||
|
decimal? ReturnOverMaxDrawdown,
|
||||||
|
|
||||||
int Rank,
|
int Rank,
|
||||||
bool IsOnWatchlist,
|
bool IsOnWatchlist,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ public class AnalyticsService : IAnalyticsService
|
|||||||
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
|
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
|
||||||
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,
|
||||||
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,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 MedianPostFillDriftPct { get; set; }
|
||||||
public decimal NetEdgeAfterFeesPct { 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
|
// Navigation
|
||||||
public virtual Trader Trader { get; set; } = null!;
|
public virtual Trader Trader { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1284
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")
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("LongestLosingStreakDays")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("MaxDrawdownUsd")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
b.Property<decimal>("MedianHoldDurationHours")
|
b.Property<decimal>("MedianHoldDurationHours")
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
@@ -722,6 +728,9 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("decimal(18,4)");
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnlVolatilityUsd")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
b.Property<string>("PriceBandProfileJson")
|
b.Property<string>("PriceBandProfileJson")
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,20 @@ public class PositionPnLEngine : IPositionPnLEngine
|
|||||||
// Count Trades30d
|
// Count Trades30d
|
||||||
analytics.Trades30d = trades.Where(t => t.ExecutedAt >= cutoff30d).Sum(t => t.AggregatedCount ?? 1);
|
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
|
// 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) =
|
||||||
|
|||||||
Reference in New Issue
Block a user