#5 Edge-freshness: is a master edge current or stale?

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 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-24 11:44:23 +02:00
parent b7bee86ed7
commit e5ce69793b
6 changed files with 168 additions and 0 deletions
@@ -105,6 +105,20 @@ public static class TraderEndpoints
return drift is not null ? Results.Ok(drift) : Results.NoContent(); 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) => group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{ {
var positionsA = await db.TraderPositions var positionsA = await db.TraderPositions
@@ -1220,3 +1220,26 @@ a:hover { color: #8ab8ff; }
border-radius: 6px; border-radius: 6px;
padding: 3px 8px; 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);
}
@@ -539,6 +539,7 @@
<!-- Right Main Area --> <!-- Right Main Area -->
<div class="detail-main"> <div class="detail-main">
<div id="td-drift-banner" class="drift-banner" style="display:none;"></div> <div id="td-drift-banner" class="drift-banner" style="display:none;"></div>
<div id="td-edge-freshness" class="edge-badge-wrap" style="display:none;"></div>
<div class="tabs-nav"> <div class="tabs-nav">
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse &amp; KI</button> <button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse &amp; KI</button>
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button> <button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
+18
View File
@@ -515,6 +515,23 @@ async function loadTraders() {
`).join(''); `).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 = `
<span class="edge-badge ${cls}">${label}</span>
<span class="edge-detail">${ef.detail} · Frische-Score ${Number(ef.score).toFixed(0)}/100
(${ef.recentClosedMarkets} vs ${ef.olderClosedMarkets} Märkte)</span>`;
el.style.display = 'flex';
}
async function loadTraderDrift(id) { async function loadTraderDrift(id) {
const banner = document.getElementById('td-drift-banner'); const banner = document.getElementById('td-drift-banner');
if (!banner) return; if (!banner) return;
@@ -689,6 +706,7 @@ async function viewTrader(id) {
traitsEl.innerHTML = ''; traitsEl.innerHTML = '';
} }
loadTraderDrift(id); loadTraderDrift(id);
loadTraderEdgeFreshness(id);
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate); document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d); document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
@@ -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);
}
}
@@ -0,0 +1,58 @@
using Predictalytics.Domain.Entities;
namespace Predictalytics.Application.Services;
/// <summary>Result of comparing a trader's older out-of-sample window to the recent one (#5).</summary>
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);
/// <summary>
/// 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 60180).
/// </summary>
public static class EdgeFreshnessCalculator
{
/// <summary>Below this many closed markets in a window, we can't judge the edge.</summary>
public const int MinClosedMarkets = 5;
/// <summary>Return% per market dropping by at least this (recent vs older) counts as fading.</summary>
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);
}
}