From e5ce69793b62a63472a07ca1979d245c72f3bdb8 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 24 Jul 2026 11:44:23 +0200 Subject: [PATCH] @ #5 Edge-freshness: is a master edge current or stale? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compares the recent out-of-sample window (last 60d) against the older one (day 60-180) — both already produced per run by the analytics worker, so no worker changes and no schema change. - Pure EdgeFreshnessCalculator (Application): Fresh / Stable / Fading / Insufficient + a 0-100 freshness score. Fading when return/market drops past a threshold or a once-strong profit factor collapses below break-even. - GET /api/traders/{id}/edge-freshness reads the two windows and computes it. - UI: a colored edge-freshness badge on the trader detail page (🟢 frisch / 🟡 stabil / 🔴 verblasst) with the return delta and market counts. - Tests: 5 scenarios (insufficient, fresh, return collapse, PF collapse, stable). Co-Authored-By: Claude Opus 4.8 @ --- .../Endpoints/TraderEndpoints.cs | 14 +++++ src/Predictalytics.Api/wwwroot/css/style.css | 23 ++++++++ src/Predictalytics.Api/wwwroot/index.html | 1 + src/Predictalytics.Api/wwwroot/js/app.js | 18 ++++++ .../Services/EdgeFreshnessCalculatorTests.cs | 54 +++++++++++++++++ .../Services/EdgeFreshnessCalculator.cs | 58 +++++++++++++++++++ 6 files changed, 168 insertions(+) create mode 100644 src/Predictalytics.Application.Tests/Services/EdgeFreshnessCalculatorTests.cs create mode 100644 src/Predictalytics.Application/Services/EdgeFreshnessCalculator.cs 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); + } +}