@
#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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -539,6 +539,7 @@
|
||||
<!-- Right Main Area -->
|
||||
<div class="detail-main">
|
||||
<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">
|
||||
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse & KI</button>
|
||||
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
|
||||
|
||||
@@ -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 = `
|
||||
<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) {
|
||||
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);
|
||||
|
||||
@@ -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 60–180).
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user