diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs
index 74aab38..fdda285 100644
--- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs
+++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs
@@ -105,6 +105,20 @@ public static class TraderEndpoints
return drift is not null ? Results.Ok(drift) : Results.NoContent();
});
+ // Edge-freshness: is the recent out-of-sample window still as strong as the older one? (#5)
+ group.MapGet("/{id:int}/edge-freshness", async (int id, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
+ {
+ var windows = await db.TraderWindowMetrics
+ .Where(w => w.TraderId == id)
+ .OrderByDescending(w => w.WindowEnd)
+ .ToListAsync(ct);
+ if (windows.Count < 2) return Results.NoContent();
+
+ var recent = windows[0];
+ var older = windows[1];
+ return Results.Ok(Predictalytics.Application.Services.EdgeFreshnessCalculator.Compute(older, recent));
+ });
+
group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{
var positionsA = await db.TraderPositions
diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css
index 382c8c0..5ccb31f 100644
--- a/src/Predictalytics.Api/wwwroot/css/style.css
+++ b/src/Predictalytics.Api/wwwroot/css/style.css
@@ -1220,3 +1220,26 @@ a:hover { color: #8ab8ff; }
border-radius: 6px;
padding: 3px 8px;
}
+
+/* ─── Edge-freshness badge (trader detail) ─── */
+.edge-badge-wrap {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+.edge-badge {
+ font-weight: 700;
+ font-size: 12px;
+ border-radius: 6px;
+ padding: 4px 10px;
+ white-space: nowrap;
+}
+.edge-fresh { color: var(--success); background: var(--success-glow); border: 1px solid var(--success); }
+.edge-stable { color: var(--warning); background: rgba(255,149,0,0.12); border: 1px solid var(--warning); }
+.edge-fading { color: var(--danger); background: var(--danger-glow); border: 1px solid var(--danger); }
+.edge-detail {
+ font-size: 12px;
+ color: var(--text-muted);
+}
diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html
index e916edf..3a23479 100644
--- a/src/Predictalytics.Api/wwwroot/index.html
+++ b/src/Predictalytics.Api/wwwroot/index.html
@@ -539,6 +539,7 @@
+
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js
index c8b3f0e..18b3315 100644
--- a/src/Predictalytics.Api/wwwroot/js/app.js
+++ b/src/Predictalytics.Api/wwwroot/js/app.js
@@ -515,6 +515,23 @@ async function loadTraders() {
`).join('');
}
+async function loadTraderEdgeFreshness(id) {
+ const el = document.getElementById('td-edge-freshness');
+ if (!el) return;
+ el.style.display = 'none';
+ const ef = await api(`/api/traders/${id}/edge-freshness`);
+ // api() returns `true` for the 204 (no two windows yet) case and null on error.
+ if (!ef || ef === true || ef.verdict === 'Insufficient') return;
+
+ const label = { Fresh: '🟢 Edge frisch', Stable: '🟡 Edge stabil', Fading: '🔴 Edge verblasst' }[ef.verdict] || ef.verdict;
+ const cls = { Fresh: 'edge-fresh', Stable: 'edge-stable', Fading: 'edge-fading' }[ef.verdict] || '';
+ el.innerHTML = `
+ ${label}
+ ${ef.detail} · Frische-Score ${Number(ef.score).toFixed(0)}/100
+ (${ef.recentClosedMarkets} vs ${ef.olderClosedMarkets} Märkte)`;
+ el.style.display = 'flex';
+}
+
async function loadTraderDrift(id) {
const banner = document.getElementById('td-drift-banner');
if (!banner) return;
@@ -689,6 +706,7 @@ async function viewTrader(id) {
traitsEl.innerHTML = '';
}
loadTraderDrift(id);
+ loadTraderEdgeFreshness(id);
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
diff --git a/src/Predictalytics.Application.Tests/Services/EdgeFreshnessCalculatorTests.cs b/src/Predictalytics.Application.Tests/Services/EdgeFreshnessCalculatorTests.cs
new file mode 100644
index 0000000..0e72201
--- /dev/null
+++ b/src/Predictalytics.Application.Tests/Services/EdgeFreshnessCalculatorTests.cs
@@ -0,0 +1,54 @@
+using Predictalytics.Application.Services;
+using Predictalytics.Domain.Entities;
+using Xunit;
+
+namespace Predictalytics.Application.Tests.Services;
+
+public class EdgeFreshnessCalculatorTests
+{
+ private static TraderWindowMetrics Win(int closed, decimal avgReturn, decimal? pf = null)
+ => new() { ClosedMarkets = closed, AvgReturnPct = avgReturn, ProfitFactor = pf };
+
+ [Fact]
+ public void TooFewMarkets_IsInsufficient()
+ {
+ var r = EdgeFreshnessCalculator.Compute(Win(3, 10m), Win(20, 8m));
+ Assert.Equal("Insufficient", r.Verdict);
+ }
+
+ [Fact]
+ public void HoldingEdge_IsFresh()
+ {
+ // Recent >= older and positive.
+ var r = EdgeFreshnessCalculator.Compute(older: Win(30, 6m), recent: Win(25, 9m));
+ Assert.Equal("Fresh", r.Verdict);
+ Assert.True(r.Score > 50m);
+ }
+
+ [Fact]
+ public void ReturnCollapse_IsFading()
+ {
+ // Return per market dropped well beyond the fade threshold.
+ var r = EdgeFreshnessCalculator.Compute(older: Win(30, 12m), recent: Win(25, 2m));
+ Assert.Equal("Fading", r.Verdict);
+ Assert.True(r.Score < 50m);
+ }
+
+ [Fact]
+ public void ProfitFactorCollapse_IsFading_EvenOnSmallReturnDrop()
+ {
+ // Return barely moves, but a once-strong profit factor fell below break-even.
+ var r = EdgeFreshnessCalculator.Compute(
+ older: Win(30, 5m, pf: 1.6m),
+ recent: Win(25, 4m, pf: 0.8m));
+ Assert.Equal("Fading", r.Verdict);
+ }
+
+ [Fact]
+ public void MildDecline_IsStable()
+ {
+ // Small drop (< fade threshold), not an improvement -> Stable.
+ var r = EdgeFreshnessCalculator.Compute(older: Win(30, 6m), recent: Win(25, 4m));
+ Assert.Equal("Stable", r.Verdict);
+ }
+}
diff --git a/src/Predictalytics.Application/Services/EdgeFreshnessCalculator.cs b/src/Predictalytics.Application/Services/EdgeFreshnessCalculator.cs
new file mode 100644
index 0000000..a83dbc5
--- /dev/null
+++ b/src/Predictalytics.Application/Services/EdgeFreshnessCalculator.cs
@@ -0,0 +1,58 @@
+using Predictalytics.Domain.Entities;
+
+namespace Predictalytics.Application.Services;
+
+/// Result of comparing a trader's older out-of-sample window to the recent one (#5).
+public sealed record EdgeFreshnessResult(
+ string Verdict, // "Fresh" | "Stable" | "Fading" | "Insufficient"
+ decimal Score, // 0..100; 50 = flat, higher = edge improving
+ decimal RecentReturnPct,
+ decimal OlderReturnPct,
+ int RecentClosedMarkets,
+ int OlderClosedMarkets,
+ string Detail);
+
+///
+/// Edge-freshness (#5): compares the recent out-of-sample window (A) against the older one (B) to
+/// answer "is this trader's edge current or stale?". Pure — no DB access, fully unit-tested.
+/// The two windows are produced by the analytics worker (recent = last 60d, older = day 60–180).
+///
+public static class EdgeFreshnessCalculator
+{
+ /// Below this many closed markets in a window, we can't judge the edge.
+ public const int MinClosedMarkets = 5;
+
+ /// Return% per market dropping by at least this (recent vs older) counts as fading.
+ public const decimal FadeReturnDropPct = 3m;
+
+ public static EdgeFreshnessResult Compute(TraderWindowMetrics older, TraderWindowMetrics recent)
+ {
+ if (recent.ClosedMarkets < MinClosedMarkets || older.ClosedMarkets < MinClosedMarkets)
+ {
+ return new EdgeFreshnessResult("Insufficient", 0m,
+ recent.AvgReturnPct, older.AvgReturnPct,
+ recent.ClosedMarkets, older.ClosedMarkets,
+ "Zu wenige abgeschlossene Märkte in einem Fenster für ein Urteil.");
+ }
+
+ var delta = recent.AvgReturnPct - older.AvgReturnPct;
+ var score = Math.Clamp(50m + delta * 5m, 0m, 100m);
+
+ // A once-profitable edge that collapsed below break-even is the clearest fade signal.
+ var profitFactorCollapse = (older.ProfitFactor ?? 0m) >= 1.3m
+ && recent.ProfitFactor.HasValue && recent.ProfitFactor.Value < 1.0m;
+
+ string verdict;
+ if (delta <= -FadeReturnDropPct || profitFactorCollapse)
+ verdict = "Fading";
+ else if (delta >= 0m && recent.AvgReturnPct > 0m)
+ verdict = "Fresh";
+ else
+ verdict = "Stable";
+
+ var detail = $"Rendite/Markt: {older.AvgReturnPct:F1}% (alt) → {recent.AvgReturnPct:F1}% (neu)";
+ return new EdgeFreshnessResult(verdict, Math.Round(score, 1),
+ recent.AvgReturnPct, older.AvgReturnPct,
+ recent.ClosedMarkets, older.ClosedMarkets, detail);
+ }
+}