WebUI Redesign and Component 1: category mapper fixes

This commit is contained in:
Richard
2026-07-19 10:51:07 +02:00
parent c3e66d6cca
commit 7045002ca3
40 changed files with 7037 additions and 971 deletions
+40 -17
View File
@@ -1,5 +1,10 @@
using Predictalytics.Api.Endpoints;
using Predictalytics.Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using System;
namespace Predictalytics.Api;
@@ -14,8 +19,10 @@ public static class ApiConfiguration
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:5000" };
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
var app = builder.Build();
@@ -29,28 +36,44 @@ public static class ApiConfiguration
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
});
app.MapPredictalyticsEndpoints();
// Register both Read and Control endpoints for the local host
app.MapPredictalyticsReadEndpoints();
app.MapPredictalyticsControlEndpoints();
return app;
}
/// <summary>
/// Single source of truth for ALL API endpoint registrations.
/// Both the standalone API and the embedded WinForms Kestrel server MUST use
/// this method — never register endpoints individually in either host, or the
/// two servers drift apart (missing /api/watchlist in the embedded host was
/// exactly this class of bug).
/// Registers all Read-only endpoints designed for viewing and analysis.
/// Safely exposed publicly since these endpoints perform no DB writes.
/// </summary>
public static void MapPredictalyticsEndpoints(this WebApplication app)
public static void MapPredictalyticsReadEndpoints(this IEndpointRouteBuilder routes)
{
app.MapDashboardEndpoints();
app.MapTraderEndpoints();
app.MapAlertEndpoints();
app.MapMarketEndpoints();
app.MapSearchEndpoints();
app.MapJobEndpoints();
app.MapDevEndpoints();
app.MapWatchlistEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
routes.MapDashboardEndpoints();
routes.MapTraderReadEndpoints();
routes.MapAlertReadEndpoints();
routes.MapMarketEndpoints();
routes.MapSearchEndpoints();
routes.MapWatchlistEndpoints();
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
routes.MapGet("/api/capabilities", (IConfiguration config) =>
{
var canControl = config.GetValue<bool>("ApiSettings:CanControl", true);
var authRequired = config.GetValue<bool>("ApiSettings:AuthRequired", false);
return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired });
});
}
/// <summary>
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
/// Excluded from the Public API routing to ensure system security.
/// </summary>
public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapTraderControlEndpoints();
routes.MapAlertControlEndpoints();
routes.MapJobEndpoints();
routes.MapDevEndpoints();
}
}
@@ -1,15 +1,23 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints;
public static class AlertEndpoints
{
public static void MapAlertEndpoints(this WebApplication app)
public static void MapAlertReadEndpoints(this IEndpointRouteBuilder routes)
{
var group = app.MapGroup("/api/alerts").WithTags("Alerts");
var group = routes.MapGroup("/api/alerts").WithTags("Alerts");
group.MapGet("/", async (IAlertService svc, int? count, bool? unreadOnly, CancellationToken ct) =>
Results.Ok(await svc.GetRecentAlertsAsync(count ?? 50, unreadOnly ?? false, ct)));
}
public static void MapAlertControlEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/alerts").WithTags("Alerts");
group.MapPut("/{id:int}/read", async (int id, IAlertService svc, CancellationToken ct) =>
{
@@ -1,12 +1,15 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints;
public static class DashboardEndpoints
{
public static void MapDashboardEndpoints(this WebApplication app)
public static void MapDashboardEndpoints(this IEndpointRouteBuilder routes)
{
app.MapGet("/api/dashboard", async (IAnalyticsService svc, CancellationToken ct) =>
routes.MapGet("/api/dashboard", async (IAnalyticsService svc, CancellationToken ct) =>
Results.Ok(await svc.GetDashboardAsync(ct)))
.WithTags("Dashboard");
}
@@ -1,12 +1,15 @@
using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints;
public static class MarketEndpoints
{
public static void MapMarketEndpoints(this WebApplication app)
public static void MapMarketEndpoints(this IEndpointRouteBuilder routes)
{
var group = app.MapGroup("/api/markets").WithTags("Markets");
var group = routes.MapGroup("/api/markets").WithTags("Markets");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, string? category, string? query, CancellationToken ct) =>
{
@@ -7,9 +7,9 @@ namespace Predictalytics.Api.Endpoints;
public static class SearchEndpoints
{
public static void MapSearchEndpoints(this WebApplication app)
public static void MapSearchEndpoints(this IEndpointRouteBuilder routes)
{
app.MapGet("/api/search", async (string q, IAnalyticsService svc, CancellationToken ct) =>
routes.MapGet("/api/search", async (string q, IAnalyticsService svc, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(q)) return Results.BadRequest("Query cannot be empty");
var results = await svc.SearchAsync(q, ct);
@@ -1,14 +1,17 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints;
public static class TraderEndpoints
{
public static void MapTraderEndpoints(this WebApplication app)
public static void MapTraderReadEndpoints(this IEndpointRouteBuilder routes)
{
var group = app.MapGroup("/api/traders").WithTags("Traders");
var group = routes.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, ct)));
@@ -22,48 +25,12 @@ public static class TraderEndpoints
return detail is not null ? Results.Ok(detail) : Results.NotFound();
});
group.MapGet("/{id:int}/deep-dive", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var dd = await svc.GetTraderDeepDiveAsync(id, ct);
return dd is not null ? Results.Ok(dd) : Results.NotFound();
});
group.MapGet("/{id:int}/positions", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var positions = await svc.GetTraderPositionsAsync(id, ct);
return Results.Ok(positions);
});
group.MapPost("/{id:int}/priority", async (int id, int? score, IScoringService svc, CancellationToken ct) =>
{
await svc.SetManualOverrideAsync(id, score, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.AddAsync(id, "Watched via UI", null, ct);
return Results.Ok();
});
group.MapDelete("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.RemoveByTraderIdAsync(id, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/ai-analysis", async (int id, bool manual, IAiStrategyAnalysisService aiSvc, CancellationToken ct) =>
{
var summary = await aiSvc.AnalyzeTraderStrategyAsync(id, manual, ct);
return Results.Ok(new { summary });
});
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{
var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id });
});
group.MapGet("/{id:int}/profile", async (int id, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{
var trader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == id, ct);
@@ -128,4 +95,45 @@ public static class TraderEndpoints
));
});
}
public static void MapTraderControlEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/{id:int}/deep-dive", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var dd = await svc.GetTraderDeepDiveAsync(id, ct);
return dd is not null ? Results.Ok(dd) : Results.NotFound();
});
group.MapPost("/{id:int}/priority", async (int id, int? score, IScoringService svc, CancellationToken ct) =>
{
await svc.SetManualOverrideAsync(id, score, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.AddAsync(id, "Watched via UI", null, ct);
return Results.Ok();
});
group.MapDelete("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.RemoveByTraderIdAsync(id, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/ai-analysis", async (int id, bool manual, IAiStrategyAnalysisService aiSvc, CancellationToken ct) =>
{
var summary = await aiSvc.AnalyzeTraderStrategyAsync(id, manual, ct);
return Results.Ok(new { summary });
});
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{
var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id });
});
}
}
+9
View File
@@ -10,5 +10,14 @@
"OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1",
"ApiKey": "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"
},
"Egress": {
"Channels": []
},
"ApiSettings": {
"CanControl": true,
"AuthRequired": false,
"AllowedOrigins": [ "http://localhost:5000" ],
"ReadOnlyDatabase": false
}
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Predictalytics API Reference</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
pre { margin: 0; font-family: 'Roboto Mono', monospace; }
</style>
</head>
<body>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.22), transparent 60%), #0a0d14; color:#EDEFF5;">
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<a href="./landing.html" style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em; color:white;">Predictalytics<span style="color:#5b9dff;">.</span></div>
</a>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="./landing.html#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="./landing.html#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Preise</a>
<a href="./landing.html#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<div style="font-size:13.5px; font-weight:700; color:#5b9dff;">API Docs</div>
</nav>
<a href="./index.html" style="padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); color:white;">Dashboard</a>
</header>
<div style="display:flex; max-width:1280px; margin:0 auto; padding:0 24px;">
<!-- SIDEBAR -->
<aside style="width:230px; flex:none; padding:36px 16px; position:sticky; top:68px; align-self:flex-start; height:calc(100vh - 68px); overflow-y:auto;">
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:0 12px 8px;">GETTING STARTED</div>
<div onclick="scrollToSection('sec-auth')" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:#EDEFF5; margin-bottom:2px;">Authentication</div>
<div onclick="scrollToSection('sec-limits')" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:#8B93A7; margin-bottom:2px;">Rate Limits</div>
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:16px 12px 8px;">ENDPOINTS</div>
<div id="sidebarEndpoints" style="display:flex; flex-direction:column; gap:2px;"></div>
</aside>
<!-- CONTENT -->
<main style="flex:1; min-width:0; padding:36px 40px 100px;">
<h1 style="margin:0 0 10px; font-size:30px; font-weight:800; letter-spacing:-0.02em;">API Reference</h1>
<p style="margin:0 0 32px; color:#8B93A7; font-size:14.5px; max-width:640px; line-height:1.6;">Programmatischer Zugriff auf jeden Trader, Markt und historischen Trade in Predictalytics. Alle Endpunkte liefern JSON über HTTP(S).</p>
<!-- Auth -->
<section id="sec-auth" style="margin-bottom:40px; scroll-margin-top:90px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.7; margin:0 0 14px;">Übergebe deinen API-Key (sofern aktiv) als Bearer-Token in jedem Request-Header.</p>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:18px 20px; font-family:'Roboto Mono'; font-size:13px; color:#C7CCDA; overflow-x:auto;">
<pre>curl http://localhost:5000/api/dashboard \
-H "Authorization: Bearer DEIN_API_KEY"</pre>
</div>
</section>
<!-- Rate limits -->
<section id="sec-limits" style="margin-bottom:40px; scroll-margin-top:90px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Rate limits</h2>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:14px;">
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">Lokal / Dev</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">Unlimitiert</div>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">Pro-Plan</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">2.000 / Tag</div>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">Enterprise</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">Custom</div>
</div>
</div>
</section>
<!-- Dynamic Endpoints -->
<div id="endpointsContainer"></div>
</main>
</div>
</div>
<script>
const endpoints = [
{
id: "ep-capabilities",
method: "GET",
path: "/api/capabilities",
desc: "Gibt die Berechtigungen (CanControl, AuthRequired) des aktuellen Webservers zurück.",
params: [],
response: '{\n "canControl": true,\n "authRequired": false\n}'
},
{
id: "ep-dashboard",
method: "GET",
path: "/api/dashboard",
desc: "Liefert globale Statistiken sowie Listen von Top-Tradern und aktuellen Trades.",
params: [],
response: '{\n "totalTraders": 142,\n "activeTraders24h": 45,\n "volume24h": 850420.50,\n "topTraders": [...],\n "recentTrades": [...]\n}'
},
{
id: "ep-traders",
method: "GET",
path: "/api/traders",
desc: "Liefert eine gefilterte Liste aller überwachten Trader.",
params: [
{ name: "platform", type: "string", desc: "Z.B. Polymarket, Limitless" },
{ name: "sortBy", type: "string", desc: "score, winrate, pnl, name" },
{ name: "minWinRate", type: "number", desc: "Mindest-Win-Rate in %" }
],
response: '[\n {\n "id": 12,\n "displayName": "quant_owl",\n "platform": "Polymarket",\n "combinedScore": 91.5,\n "winRate": 64.2,\n "totalPnl": 216420.00\n }\n]'
},
{
id: "ep-trader-detail",
method: "GET",
path: "/api/traders/{id}",
desc: "Detaillierte Informationen, P&L-Statistiken, Traits und Trade-Historie eines Traders.",
params: [
{ name: "id", type: "integer", desc: "Interne ID des Traders" }
],
response: '{\n "id": 12,\n "displayName": "quant_owl",\n "platform": "Polymarket",\n "traits": [{"trait": "Whale", "value": 1.0}],\n "recentTrades": [...]\n}'
},
{
id: "ep-markets",
method: "GET",
path: "/api/markets",
desc: "Gibt alle erfassten Wetten/Märkte zurück.",
params: [
{ name: "platform", type: "string", desc: "Filter nach Plattform" }
],
response: '[\n {\n "id": 8,\n "question": "Will Ethereum exceed $4,000 in July?",\n "volume": 2541090.00,\n "isResolved": false\n }\n]'
}
];
// Render Sidebar
document.getElementById('sidebarEndpoints').innerHTML = endpoints.map(ep => {
let methodColor = ep.method === 'GET' ? '#12D48A' : '#ff9d4c';
return `
<div onclick="scrollToSection('${ep.id}')" style="cursor:pointer; display:flex; align-items:center; gap:8px; padding:9px 12px; border-radius:9px; font-size:13px; font-weight:600; color:#8B93A7; margin-bottom:2px;">
<span style="font-size:10px; font-weight:800; font-family:'Roboto Mono'; color:${methodColor};">${ep.method}</span>
<span>${ep.path}</span>
</div>
`;
}).join('');
// Render Content Endpoints
document.getElementById('endpointsContainer').innerHTML = endpoints.map(ep => {
let methodColor = ep.method === 'GET' ? '#12D48A' : '#ff9d4c';
let methodBg = ep.method === 'GET' ? 'rgba(18,212,138,0.12)' : 'rgba(255,157,76,0.12)';
let paramsHtml = ep.params.length > 0 ? ep.params.map(p => `
<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px; font-size:12.5px;">
<span style="font-family:'Roboto Mono'; font-weight:700; color:#5b9dff;">${p.name}</span>
<span style="color:#5B6377; font-size:11.5px;">${p.type}</span>
<span style="color:#8B93A7;">${p.desc}</span>
</div>
`).join('') : '<div style="font-size:12.5px; color:#5B6377;">Keine Parameter erforderlich.</div>';
return `
<section id="${ep.id}" style="margin-bottom:40px; scroll-margin-top:90px;">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
<span style="font-size:11px; font-weight:800; font-family:'Roboto Mono'; padding:4px 9px; border-radius:6px; background:${methodBg}; color:${methodColor};">${ep.method}</span>
<span style="font-family:'Roboto Mono'; font-size:14.5px; font-weight:700; color:#EDEFF5;">${ep.path}</span>
</div>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.6; margin:0 0 14px;">${ep.desc}</p>
<div style="display:grid; grid-template-columns:1fr 1.2fr; gap:16px;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px;">
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Parameters</div>
${paramsHtml}
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px; overflow-x:auto;">
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Response (JSON)</div>
<pre style="font-size:12px; color:#C7CCDA; line-height:1.4;">${ep.response}</pre>
</div>
</div>
</section>
`;
}).join('');
window.scrollToSection = function(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth' });
};
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+191 -49
View File
@@ -168,21 +168,125 @@ const fmt = {
let platformChart, tierChart;
function getChartColors() {
const isDark = html.getAttribute('data-theme') === 'dark';
return {
text: isDark ? '#AAAAAA' : '#666666',
grid: isDark ? '#2A2C33' : '#E8E9EC',
bg: isDark ? '#16181D' : '#FFFFFF'
text: '#8B93A7',
grid: 'rgba(255,255,255,0.07)',
bg: 'rgba(255,255,255,0.045)'
};
}
function updateChartColors() {
const c = getChartColors();
[platformChart, tierChart].forEach(chart => {
if (!chart) return;
if (chart.options.plugins?.legend) chart.options.plugins.legend.labels.color = c.text;
chart.update();
});
function getAvatarBg(id) {
const gradients = [
'linear-gradient(135deg,#1652F0,#4c8dff)',
'linear-gradient(135deg,#12D48A,#0a8f5f)',
'linear-gradient(135deg,#7c5cff,#4c8dff)',
'linear-gradient(135deg,#ff9d4c,#ff6b8a)',
'linear-gradient(135deg,#F6465D,#ff6b8a)'
];
return gradients[id % gradients.length];
}
function getTraitClass(trait) {
if (!trait) return 'tier-unknown';
const lower = trait.toLowerCase();
if (lower.includes('winrate') || lower.includes('wins') || lower.includes('farming') || lower.includes('insider')) return 'trait-winrate';
if (lower.includes('volume') || lower.includes('amounts') || lower.includes('sizes') || lower.includes('stake')) return 'trait-volume';
if (lower.includes('whale') || lower.includes('scalper') || lower.includes('martingale')) return 'trait-whales';
if (lower.includes('sentiment') || lower.includes('cadence') || lower.includes('24_7') || lower.includes('copyable') || lower.includes('wallet')) return 'trait-sentiment';
return 'tier-unknown';
}
const TraitMetadata = {
"sub_second_cadence": {
name: "Sub-Second Cadence",
desc: "Executes trades in sub-second intervals, highly indicative of algorithmic execution."
},
"always_on_24_7": {
name: "24/7 Activity",
desc: "Trades at all hours of the day and night with very short gaps, indicating bot operation."
},
"uniform_sizes": {
name: "Uniform Sizes",
desc: "Executes trades with identical position sizes, suggesting a systematic layout."
},
"round_amounts": {
name: "Round Amounts",
desc: "Executes trades with round amounts (e.g. 100, 500, 1000 USD), common for manual traders."
},
"uses_split_merge": {
name: "Split & Merge",
desc: "Splits large positions into smaller orders or merges multiple positions to optimize slippage."
},
"both_sides_same_market": {
name: "Two-Sided Market Maker",
desc: "Buys and sells in the same market, extracting spreads or resolving inventory."
},
"resolution_farming": {
name: "Resolution Farmer",
desc: "Buys options at high probabilities (e.g., >93%) to collect predictable small returns."
},
"longshot_buyer": {
name: "Longshot Buyer",
desc: "Buys options at low probabilities (e.g., <10%), seeking rare high-payoff events."
},
"scalper": {
name: "Scalper",
desc: "Holds positions for very short periods (typically <1 hour) to lock in quick profits."
},
"holds_to_resolution": {
name: "Holds to Resolution",
desc: "Keeps positions open until the market officially resolves, avoiding early exits."
},
"fresh_wallet": {
name: "Fresh Wallet",
desc: "Wallet was created recently (less than 30 days of active history)."
},
"stable_stake_fraction": {
name: "Stable Stake",
desc: "Risk per trade is a highly stable fraction of estimated total bankroll."
},
"possible_insider": {
name: "Possible Insider",
desc: "Consistently wins low-probability bets with very low volume, indicating asymmetric information."
},
"thin_margin_wins": {
name: "Thin Margin Winner",
desc: "Consistently resolves trades with small margins of win (low ROI)."
},
"high_payoff_wins": {
name: "High Payoff Winner",
desc: "Consistently resolves trades with high margins of win (high ROI)."
},
"sells_at_loss": {
name: "Sells at Loss",
desc: "Uses strict stop-loss rules, selling options at a loss when the market goes against them."
},
"days_active": {
name: "Days Active",
desc: "The number of days this wallet has been active on-chain."
},
"trades_last_30_days": {
name: "Trades Last 30d",
desc: "The total number of trades executed in the last 30 days."
},
"martingale_pattern": {
name: "Martingale Trader",
desc: "Increases bet size after losses to attempt to recoup losses, typical of high-risk strategies."
},
"not_copyable_hf": {
name: "Uncopyable (High Freq)",
desc: "Trades at frequencies too high to replicate manually or with standard copytrading setups."
}
};
function getTraitDisplayName(traitKey) {
const meta = TraitMetadata[traitKey];
return meta ? meta.name : traitKey.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function getTraitDescription(traitKey) {
const meta = TraitMetadata[traitKey];
return meta ? meta.desc : 'No description available.';
}
// ─── Dashboard Load ───
@@ -224,19 +328,20 @@ async function loadDashboard() {
// Recent Trades table
const rBody = document.getElementById('recentTradesBody');
rBody.innerHTML = data.recentTrades.map(t => `
<tr>
<tr onclick="viewTrader(${t.traderId})">
<td>${fmt.time(t.executedAt)}</td>
<td onclick="viewTrader(${t.traderId})" style="cursor:pointer; color:var(--primary)">${t.traderName}</td>
<td onclick="${t.dbMarketId ? `viewMarket(${t.dbMarketId})` : `''`}" style="cursor:${t.dbMarketId ? 'pointer' : 'default'}" title="Market ID: ${t.marketId}">
${t.marketName || (t.marketId.length > 20 ? t.marketId.substring(0,20)+'...' : t.marketId)}
</td>
<td><strong>${t.traderName}</strong></td>
<td>${fmt.side(t.side)}</td>
<td>${Number(t.price).toFixed(2)}</td>
<td>${fmt.num(t.size)}</td>
<td>${fmt.usd(t.amount)}</td>
<td class="num-col">${Number(t.price).toFixed(2)}</td>
<td class="num-col">${fmt.num(t.size)}</td>
</tr>
`).join('');
const headerVol = document.getElementById('headerVolumeBadge');
if (headerVol) {
headerVol.textContent = `24H VOL: ${fmt.usd(data.volume24h)}`;
}
// Platform Chart
const cc = getChartColors();
const pLabels = Object.keys(data.platformBreakdown.traderCounts);
@@ -245,25 +350,25 @@ async function loadDashboard() {
if (platformChart) platformChart.destroy();
platformChart = new Chart(document.getElementById('platformChart'), {
type: 'doughnut',
data: { labels: pLabels.length ? pLabels : ['No Data'], datasets: [{ data: pData.length ? pData : [1],
backgroundColor: ['#FF2D55', '#5AC8FA', '#FF9500', '#34C759', '#AF52DE', '#FF6B8A', '#30D158'],
data: { labels: pLabels.length ? pLabels : ['Keine Daten'], datasets: [{ data: pData.length ? pData : [1],
backgroundColor: ['#1652F0', '#12D48A', '#7c5cff', '#ff9d4c', '#F6465D', '#5b9dff'],
borderWidth: 0 }] },
options: { responsive: true, maintainAspectRatio: false, cutout: '70%',
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Inter'", size: 12 } } } } }
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Manrope'", size: 12 } } } } }
});
// Tier chart
const tierData = data.topTraders.reduce((acc, t) => { acc[t.tier] = (acc[t.tier] || 0) + 1; return acc; }, {});
if (tierChart) tierChart.destroy();
const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['No Data'];
const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['Keine Daten'];
const tData = Object.values(tierData).length ? Object.values(tierData) : [1];
tierChart = new Chart(document.getElementById('tierChart'), {
type: 'bar',
data: { labels: tLabels, datasets: [{ label: 'Traders', data: tData,
backgroundColor: '#FF2D55', borderRadius: 6, barThickness: 32 }] },
data: { labels: tLabels, datasets: [{ label: 'Trader', data: tData,
backgroundColor: '#1652F0', borderRadius: 6, barThickness: 32 }] },
options: { responsive: true, maintainAspectRatio: false,
scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Inter'" } } },
y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Inter'" } } } },
scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Manrope'" } } },
y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Manrope'" } } } },
plugins: { legend: { display: false } } }
});
}
@@ -328,26 +433,24 @@ async function loadTraders() {
});
tbody.innerHTML = data.map((t, i) => `
<tr>
<tr onclick="viewTrader(${t.id})">
<td>${i + 1}</td>
<td><strong><a href="#" onclick="viewTrader(${t.id}); return false;" style="color:var(--primary);text-decoration:none;">${t.displayName}</a></strong>${t.isSuspectedBot ? ' 🤖' : ''}</td>
<td><strong>${t.displayName}</strong>${t.isSuspectedBot ? ' 🤖' : ''}</td>
<td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${Number(t.copytradingQualityScore || 0).toFixed(1)}</td>
<td>${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
<td>${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td>
<td class="num-col"><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td class="num-col">${Number(t.copytradingQualityScore || 0).toFixed(1)}</td>
<td class="num-col">${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
<td class="num-col">${fmt.pct(t.winRate)}</td>
<td class="num-col">${fmt.pnl(t.totalPnl)}</td>
<td>${t.trades30d} | ${t.totalTrades}</td>
<td>
${t.strategy}
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => `<span style="font-size:10px; padding:2px 6px; background:var(--bg-input); border-radius:10px;">${tr}</span>`).join('') + '</div>' : ''}
</td>
<td>
<div style="display:flex; gap:4px;">
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button>
<button class="btn-sm" onclick="queueHistorySync(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Sync</button>
<button class="btn-sm" onclick="queueTraderAnalysis(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Analyze</button>
</div>
${t.strategy || '—'}
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => {
const rawKey = tr.trait || tr;
const name = getTraitDisplayName(rawKey);
const desc = getTraitDescription(rawKey);
return `<span class="tier-badge ${getTraitClass(rawKey)}" title="${desc}">${name}</span>`;
}).join('') + '</div>' : ''}
</td>
</tr>
`).join('');
@@ -454,17 +557,30 @@ async function viewTrader(id) {
// Reset tabs to default (Analytics)
switchTraderTab('td-tab-analytics');
document.getElementById('td-name').textContent = t.displayName;
document.getElementById('td-name').textContent = `Trader-Profil: ${t.displayName}`;
document.getElementById('td-displayName').textContent = t.displayName;
document.getElementById('td-platform').textContent = t.platform;
document.getElementById('td-platformId').textContent = t.platformUserId;
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
document.getElementById('td-strategy').textContent = t.strategy;
const avatarEl = document.getElementById('td-avatar');
if (avatarEl) {
avatarEl.textContent = t.displayName ? t.displayName[0].toUpperCase() : 'P';
avatarEl.style.background = getAvatarBg(id);
}
const traitsContainer = document.getElementById('td-traits-container');
const traitsEl = document.getElementById('td-traits');
if (t.traits && t.traits.length > 0) {
traitsContainer.style.display = 'block';
traitsEl.innerHTML = t.traits.map(tr => `<span title="Value: ${Number(tr.value).toFixed(4)}" style="font-size:11px; padding:2px 8px; background:var(--bg-input); border-radius:12px; border:1px solid var(--border);">${tr.trait}</span>`).join('');
traitsEl.innerHTML = t.traits.map(tr => {
const rawKey = tr.trait || tr;
const name = getTraitDisplayName(rawKey);
const desc = getTraitDescription(rawKey);
const scoreVal = tr.value !== undefined ? ` (Value: ${Number(tr.value).toFixed(4)})` : '';
return `<span class="tier-badge ${getTraitClass(rawKey)}" title="${desc}${scoreVal}">${name}</span>`;
}).join('');
} else {
traitsContainer.style.display = 'none';
traitsEl.innerHTML = '';
@@ -487,7 +603,7 @@ async function viewTrader(id) {
document.getElementById('td-median-win-loss').innerHTML = `<span class="side-buy">+${Number(medianWin).toFixed(1)}%</span> / <span class="side-sell">${Number(medianLoss).toFixed(1)}%</span>`;
document.getElementById('td-profit-factor').textContent = t.profitFactor ? Number(t.profitFactor).toFixed(2) : '—';
document.getElementById('td-expectancy').innerHTML = expectancy > 0 ? `<span class="side-buy">+${expectancy.toFixed(1)}%</span>` : `<span class="side-sell">${expectancy.toFixed(1)}%</span>`;
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Bisher keine KI-Strategieanalyse durchgeführt.';
const syncBtn = document.getElementById('btn-sync-trader');
if (syncBtn) {
@@ -497,7 +613,7 @@ async function viewTrader(id) {
const deepSyncBtn = document.getElementById('btn-deep-resync-trader');
if (deepSyncBtn) {
deepSyncBtn.onclick = () => {
if (confirm("Are you sure? This will delete all compacted trades and reset positions, then fetch all historical trades via pagination.")) {
if (confirm("Bist du sicher? Dies löscht alle komprimierten Trades, setzt die Positionen zurück und lädt den gesamten Verlauf neu.")) {
queueDeepResync(id);
}
};
@@ -509,7 +625,12 @@ async function viewTrader(id) {
}
const aiBtn = document.getElementById('btn-ai-analysis');
aiBtn.onclick = () => triggerAiAnalysis(id, true);
if (window.capabilities && !window.capabilities.canControl) {
aiBtn.style.display = 'none';
} else {
aiBtn.style.display = 'inline-block';
aiBtn.onclick = () => triggerAiAnalysis(id, true);
}
const wlBtn = document.getElementById('btn-toggle-watchlist');
wlBtn.textContent = t.isOnWatchlist ? 'Watchlist (Remove)' : 'Watchlist (Add)';
@@ -750,6 +871,27 @@ async function queueBacklogAnalysis() {
// Initialize Dashboard
document.addEventListener('DOMContentLoaded', async () => {
// Load capabilities first
try {
const caps = await api('/api/capabilities');
if (caps) {
window.capabilities = caps;
if (!caps.canControl) {
// Hide control-mode guarded elements
const jobsNav = document.getElementById('nav-jobs-container');
if (jobsNav) jobsNav.style.display = 'none';
const addTraderPanel = document.getElementById('control-add-trader-panel');
if (addTraderPanel) addTraderPanel.style.display = 'none';
const traderActions = document.getElementById('control-trader-actions');
if (traderActions) traderActions.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to load capabilities', e);
}
// Load traits for filter
try {
const traits = await api('/api/traders/traits');
@@ -759,7 +901,7 @@ document.addEventListener('DOMContentLoaded', async () => {
traits.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
opt.textContent = getTraitDisplayName(t);
filterTrait.appendChild(opt);
});
}
+302
View File
@@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Predictalytics — See every trader and market before the crowd does</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; transition: all 0.2s; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.4s ease-out; }
</style>
</head>
<body>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5; overflow-x:hidden;">
<!-- NAV -->
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">Predictalytics<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Preise</a>
<a href="#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<a href="./docs.html" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">API Docs</a>
</nav>
<div style="display:flex; align-items:center; gap:10px;">
<div onclick="openAuth('login')" style="cursor:pointer; padding:9px 18px; border-radius:10px; font-size:13.5px; font-weight:700; color:#EDEFF5;">Login</div>
<div onclick="openAuth('register')" style="cursor:pointer; padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 4px 20px rgba(22,82,240,0.4);">Registrieren</div>
</div>
</header>
<!-- HERO -->
<section style="padding:100px 48px 80px; max-width:1080px; margin:0 auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:22px;">
<div style="padding:6px 14px; border-radius:20px; background:rgba(22,82,240,0.12); border:1px solid rgba(22,82,240,0.3); font-size:12.5px; font-weight:700; color:#5b9dff;">Echtzeit-Onchain-Analysen · Polymarket &amp; Co.</div>
<h1 style="margin:0; font-size:52px; font-weight:800; letter-spacing:-0.03em; line-height:1.08; max-width:760px;">Erkenne jeden Trader und Markt vor der Masse</h1>
<p style="margin:0; font-size:17px; color:#8B93A7; max-width:600px; line-height:1.6;">Detaillierte Trader-Profilierung, Wallet-Clustering und Echtzeit-Marktanalysen für professionelle Akteure in Prognosemärkten.</p>
<div style="display:flex; gap:14px; margin-top:8px;">
<a href="./index.html" style="cursor:pointer; padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 30px rgba(22,82,240,0.4); color:white;">Dashboard starten</a>
<a href="#features" style="padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; color:#EDEFF5; border:1px solid rgba(255,255,255,0.14); background:rgba(255,255,255,0.04); white-space:nowrap;">Features ansehen</a>
</div>
<!-- Product Preview Mock -->
<div style="margin-top:40px; width:100%; border-radius:20px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 20px 60px rgba(0,0,0,0.4); padding:20px; display:grid; grid-template-columns:1.4fr 1fr; gap:14px; text-align:left;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7; margin-bottom:10px;">Kursverlauf YES-Token</div>
<svg viewBox="0 0 500 140" style="width:100%; height:140px;" id="heroChartSvg"></svg>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px; display:flex; flex-direction:column; gap:10px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7;">Top-Performer (24h)</div>
<div id="heroTradersContainer" style="display:flex; flex-direction:column; gap:12px;"></div>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" style="padding:80px 48px; max-width:1200px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FEATURES</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Alles was du brauchst, um den Markt zu lesen</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Konstruiert für Trader, die echtes Signal statt Rauschen suchen.</p>
</div>
<div id="featuresContainer" style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px;"></div>
</section>
<!-- PRICING -->
<section id="pricing" style="padding:80px 48px; max-width:1160px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">PREISE</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Flexible Pläne für jede Phase</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Skaliere unkompliziert hoch, wenn deine Aktivität wächst.</p>
</div>
<div id="pricingContainer" style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px; align-items:stretch;"></div>
</section>
<!-- FAQ -->
<section id="faq" style="padding:80px 48px 100px; max-width:820px; margin:0 auto;">
<div style="text-align:center; margin-bottom:44px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FAQ</div>
<h2 style="margin:0; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Häufige Fragen</h2>
</div>
<div id="faqContainer" style="display:flex; flex-direction:column; gap:10px;"></div>
</section>
<!-- FOOTER -->
<footer style="padding:32px 48px; border-top:1px solid rgba(255,255,255,0.07); display:flex; align-items:center; justify-content:space-between; color:#5B6377; font-size:12.5px;">
<div>© 2026 Predictalytics. Alle Rechte vorbehalten.</div>
<div style="display:flex; gap:20px;">
<a href="./docs.html" style="color:#5B6377;">API Docs</a>
<a href="#faq" style="color:#5B6377;">FAQ</a>
<a href="#pricing" style="color:#5B6377;">Preise</a>
</div>
</footer>
<!-- AUTH MODAL -->
<div id="authModal" onclick="closeAuth()" style="display:none; position:fixed; inset:0; z-index:100; background:rgba(6,8,13,0.7); backdrop-filter:blur(6px); align-items:center; justify-content:center;">
<div onclick="event.stopPropagation()" style="width:400px; max-width:92vw; border-radius:20px; background:rgba(16,20,29,0.9); border:1px solid rgba(255,255,255,0.1); backdrop-filter:blur(30px); box-shadow:0 30px 80px rgba(0,0,0,0.5); padding:32px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<div style="font-size:19px; font-weight:800;" id="authTitle">Log in</div>
<div onclick="closeAuth()" style="cursor:pointer; width:28px; height:28px; border-radius:8px; background:rgba(255,255,255,0.06); display:flex; align-items:center; justify-content:center; font-size:14px; color:#8B93A7;"></div>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
<div id="authNameGroup" style="display:none; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Name</label>
<input placeholder="Jane Trader" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">E-Mail</label>
<input placeholder="you@domain.com" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Passwort</label>
<input type="password" placeholder="••••••••" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<a href="./index.html" style="margin-top:6px; padding:13px; border-radius:11px; text-align:center; font-size:14px; font-weight:700; cursor:pointer; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4); color:white;" id="authSubmitLabel">Anmelden</a>
<div style="text-align:center; font-size:12.5px; color:#8B93A7; margin-top:4px;">
<span id="authSwitchPrompt">Noch kein Konto?</span> <span onclick="switchAuthMode()" style="color:#5b9dff; font-weight:700; cursor:pointer;" id="authSwitchAction">Registrieren</span>
</div>
</div>
</div>
</div>
</div>
<script>
let currentAuthMode = 'login';
function openAuth(mode) {
currentAuthMode = mode;
const modal = document.getElementById('authModal');
modal.style.display = 'flex';
modal.classList.add('fade-in');
updateAuthUI();
}
function closeAuth() {
document.getElementById('authModal').style.display = 'none';
}
function switchAuthMode() {
currentAuthMode = currentAuthMode === 'login' ? 'register' : 'login';
updateAuthUI();
}
function updateAuthUI() {
const isReg = currentAuthMode === 'register';
document.getElementById('authTitle').textContent = isReg ? 'Konto erstellen' : 'Anmelden';
document.getElementById('authSubmitLabel').textContent = isReg ? 'Konto erstellen' : 'Anmelden';
document.getElementById('authNameGroup').style.display = isReg ? 'flex' : 'none';
document.getElementById('authSwitchPrompt').textContent = isReg ? 'Bereits registriert?' : 'Noch kein Konto?';
document.getElementById('authSwitchAction').textContent = isReg ? 'Anmelden' : 'Registrieren';
}
// --- Dynamic calculations and rendering ---
function buildPath(series, w, h, pad) {
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const pts = series.map((v, i) => {
const x = pad + (i / (series.length - 1)) * innerW;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y];
});
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
return { line, area };
}
// Render SVG Hero Chart
const heroSeries = [40, 44, 41, 48, 52, 50, 58, 55, 62, 66, 63, 70, 68, 74, 71.4];
const { line, area } = buildPath(heroSeries, 500, 140, 8);
document.getElementById('heroChartSvg').innerHTML = `
<defs>
<linearGradient id="hfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
</linearGradient>
</defs>
<path d="${area}" fill="url(#hfill)" stroke="none"></path>
<path d="${line}" fill="none" stroke="#12D48A" stroke-width="2.5"></path>
`;
// Render Hero Traders
const heroTraders = [
{ handle: "quant_owl", pnl: "+$216.420", bg: "linear-gradient(135deg,#1652F0,#4c8dff)" },
{ handle: "arb_meridian", pnl: "+$454.100", bg: "linear-gradient(135deg,#12D48A,#0a8f5f)" },
{ handle: "resolvr", pnl: "+$105.800", bg: "linear-gradient(135deg,#7c5cff,#4c8dff)" },
];
document.getElementById('heroTradersContainer').innerHTML = heroTraders.map(t => `
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:24px; height:24px; border-radius:7px; background:${t.bg};"></div>
<div style="font-size:12.5px; font-weight:700; flex:1;">${t.handle}</div>
<div style="font-size:12px; font-family:'Roboto Mono'; font-weight:700; color:#12D48A;">${t.pnl}</div>
</div>
`).join('');
// Render Features
const features = [
{ title: "Trader-Verfolgung", desc: "Verfolge das vollständige Portfolio, die P&L-Kurve und das Verhalten jedes Wallets plattformübergreifend.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
{ title: "Wallet-Clustering", desc: "Erkenne verknüpfte Wallets und koordinierte Accounts hinter einer gemeinsamen Handelsstrategie.", iconBg: "rgba(124,92,255,0.14)", dotColor: "#7c5cff" },
{ title: "Markttiefe & Analysen", desc: "Orderbuch-Verläufe, Liquiditätsmetriken und Halterkonzentrationen für jeden aktiven Markt.", iconBg: "rgba(18,212,138,0.14)", dotColor: "#12D48A" },
{ title: "Echtzeit-Alerts", desc: "Erhalte Benachrichtigungen, sobald ein beobachteter Trader eine neue Position öffnet oder der Markt dreht.", iconBg: "rgba(255,157,76,0.14)", dotColor: "#ff9d4c" },
{ title: "Trader-Tags & Traits", desc: "Automatische Charakterisierung wie Arbitrageur, bot-ähnlich oder Market-Maker direkt auf einen Blick.", iconBg: "rgba(246,70,93,0.14)", dotColor: "#F6465D" },
{ title: "Programmatischer API-Zugriff", desc: "Integriere alle gesammelten Rohdaten und Auswertungen direkt in deine eigenen Skripte.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
];
document.getElementById('featuresContainer').innerHTML = features.map(f => `
<div style="padding:26px; border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px);">
<div style="width:42px; height:42px; border-radius:12px; background:${f.iconBg}; margin-bottom:16px; display:flex; align-items:center; justify-content:center;">
<div style="width:16px; height:16px; border-radius:5px; background:${f.dotColor};"></div>
</div>
<div style="font-size:16px; font-weight:700; margin-bottom:8px;">${f.title}</div>
<div style="font-size:13.5px; color:#8B93A7; line-height:1.6;">${f.desc}</div>
</div>
`).join('');
// Render Pricing
const plans = [
{ name: "Free", price: "$0", period: "/ Monat", featured: false, border: "rgba(255,255,255,0.08)", bg: "rgba(255,255,255,0.04)", btnBg: "rgba(255,255,255,0.06)", btnBorder: "rgba(255,255,255,0.12)", btnText: "#EDEFF5",
items: ["20 API-Calls / Tag", "Eingeschränkter Web-Zugang", "Grundlegende Marktübersicht", "Community-Support"] },
{ name: "Pro", price: "$99", period: "/ Monat", featured: true, border: "rgba(22,82,240,0.35)", bg: "rgba(22,82,240,0.08)", btnBg: "linear-gradient(135deg,#1652F0,#4c8dff)", btnBorder: "none", btnText: "white",
items: ["2.000 API-Calls / Tag", "Erweiterte KI-Trader-Analysen", "Direkter MCP-Zugriff für deinen KI-Agenten", "Priority-Support"] },
{ name: "Enterprise", price: "Kontakt", period: "", featured: false, border: "rgba(255,255,255,0.08)", bg: "rgba(255,255,255,0.04)", btnBg: "rgba(255,255,255,0.06)", btnBorder: "rgba(255,255,255,0.12)", btnText: "#EDEFF5",
items: ["10.000+ API-Calls / Tag", "Eigene Prompts für KI-Auswertungen", "Individuelle Quotas", "24/7 Enterprise-Support"] },
];
document.getElementById('pricingContainer').innerHTML = plans.map(p => `
<div style="position:relative; padding:30px 26px; border-radius:20px; background:${p.bg}; border:1px solid ${p.border}; backdrop-filter:blur(24px); display:flex; flex-direction:column; ${p.featured ? 'box-shadow:0 20px 50px rgba(22,82,240,0.2);' : ''}">
${p.featured ? '<div style="position:absolute; top:-13px; left:50%; transform:translateX(-50%); padding:5px 14px; border-radius:20px; background:linear-gradient(135deg,#1652F0,#4c8dff); font-size:11px; font-weight:800; letter-spacing:0.04em;">AM BELIEBTESTEN</div>' : ''}
<div style="font-size:15px; font-weight:700; margin-bottom:6px;">${p.name}</div>
<div style="display:flex; align-items:baseline; gap:4px; margin-bottom:18px;">
<div style="font-size:36px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">${p.price}</div>
<div style="font-size:13px; color:#8B93A7;">${p.period}</div>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
${p.items.map(item => `
<div style="display:flex; align-items:center; gap:9px; font-size:13.5px; color:#C7CCDA;">
<div style="width:6px; height:6px; border-radius:50%; background:#12D48A; flex:none;"></div>
<span>${item}</span>
</div>
`).join('')}
</div>
<div onclick="openAuth('register')" style="cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:${p.btnBg}; border:1px solid ${p.btnBorder}; color:${p.btnText}; ${p.featured ? 'box-shadow:0 8px 24px rgba(22,82,240,0.4);' : ''}">${p.name === 'Enterprise' ? 'Vertrieb kontaktieren' : 'Auswählen'}</div>
</div>
`).join('');
// Render FAQs
const faqs = [
{ q: "Woher stammen die gezeigten Daten?", a: "Wir überwachen Onchain-Transaktionen direkt auf der Blockchain und über Subgraphs von Polymarket und anderen Prognosemärkten in Echtzeit, kombiniert mit Auflösungs-Metadaten." },
{ q: "Wie stuft ihr Trader als Bots oder Arbitrageure ein?", a: "Unser System wertet Verhaltensmuster wie Transaktionstaktung, Order-Größe und marktübergreifende Ausführungen aus, um Wallets automatisch mit Traits wie 'Bot' oder 'Arbitrageur' zu taggen." },
{ q: "Kann ich beliebige Wallets hinzufügen?", a: "Ja. Gib einfach ein Wallet im Dashboard ein, um die historische P&L, alle Trades und Verknüpfungen direkt analysieren zu lassen." },
{ q: "Was bietet der API-Zugriff?", a: "Voller Lesezugriff auf aggregierte Bestenlisten, Detailstatistiken und Orderbuchverläufe, damit du diese Daten in deine eigenen Trading-Modelle einbetten kannst." },
];
document.getElementById('faqContainer').innerHTML = faqs.map((f, i) => `
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); overflow:hidden;">
<div onclick="toggleFaq(${i})" style="cursor:pointer; padding:18px 22px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="font-size:14.5px; font-weight:700;">${f.q}</div>
<div id="faqIcon-${i}" style="flex:none; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:16px; color:#5b9dff; transition:transform 0.2s;">+</div>
</div>
<div id="faqAnswer-${i}" style="display:none; padding:0 22px 18px; font-size:13.5px; color:#8B93A7; line-height:1.7;">${f.a}</div>
</div>
`).join('');
let activeFaq = -1;
window.toggleFaq = function(i) {
const ans = document.getElementById(`faqAnswer-${i}`);
const icon = document.getElementById(`faqIcon-${i}`);
if (activeFaq === i) {
ans.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
activeFaq = -1;
} else {
// Close previous
if (activeFaq !== -1) {
document.getElementById(`faqAnswer-${activeFaq}`).style.display = 'none';
document.getElementById(`faqIcon-${activeFaq}`).style.transform = 'rotate(0deg)';
}
ans.style.display = 'block';
icon.style.transform = 'rotate(45deg)';
activeFaq = i;
}
};
</script>
</body>
</html>
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Providers.Polymarket;
using Predictalytics.Infrastructure.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class BackendRedesignTests
{
[Fact]
public async Task AppDbContext_ShouldThrow_WhenReadOnlyDatabaseTrue()
{
// Arrange
var inMemorySettings = new Dictionary<string, string?> {
{"ApiSettings:ReadOnlyDatabase", "true"}
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings)
.Build();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: "ReadOnlyTestDb")
.Options;
using var db = new AppDbContext(options, configuration);
db.Traders.Add(new Trader
{
Id = 1,
Platform = PlatformType.Polymarket,
PlatformUserId = "0x123",
DisplayName = "Test"
});
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await db.SaveChangesAsync());
}
[Fact]
public async Task PolymarketApiClient_ShouldUseCache_ForGetMarketAsync()
{
// Arrange
var services = new ServiceCollection();
services.AddMemoryCache();
var serviceProvider = services.BuildServiceProvider();
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
// Set up mock HTTP handler returning a list containing a GammaMarketResponse
var mockResponse = new List<GammaMarketResponse>
{
new() { ConditionId = "cond-1", Question = "Will it rain?" }
};
var handlerCallCount = 0;
var mockHandler = new MockHttpMessageHandler(req =>
{
handlerCallCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content = JsonContent.Create(mockResponse);
return response;
});
var httpClient = new HttpClient(mockHandler);
var httpFactoryMock = new MockHttpClientFactory(httpClient);
var rateLimiterMock = new MockRateLimiter();
var egressPoolMock = new MockEgressPoolService();
var client = new PolymarketApiClient(
httpFactoryMock,
rateLimiterMock,
egressPoolMock,
cache,
NullLogger<PolymarketApiClient>.Instance
);
// Act
// 1. First fetch: should trigger HTTP handler
var m1 = await client.GetMarketAsync("cond-1");
// 2. Second fetch: should serve from cache
var m2 = await client.GetMarketAsync("cond-1");
// Assert
Assert.NotNull(m1);
Assert.Equal("cond-1", m1.ConditionId);
Assert.Equal("cond-1", m2?.ConditionId);
Assert.Equal(1, handlerCallCount); // Only 1 HTTP call made
}
private class MockHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public MockHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private class MockRateLimiter : IRateLimiter
{
public Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null) => Task.CompletedTask;
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null) => true;
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null) { }
}
private class MockEgressPoolService : IEgressPoolService
{
public string? GetNextChannelId() => null;
public HttpMessageInvoker? GetInvoker(string channelId) => null;
public void ReportFailure(string channelId, Exception exception) { }
public void ReportSuccess(string channelId) { }
}
private class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _sender;
public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> sender)
{
_sender = sender;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_sender(request));
}
}
}
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Configuration;
using Predictalytics.Infrastructure.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class EgressPoolTests
{
[Fact]
public void EgressPoolService_ShouldRoundRobin()
{
// Arrange
var options = Options.Create(new EgressOptions
{
Channels = new List<EgressChannelOptions>
{
new() { Id = "ch-1", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8080" },
new() { Id = "ch-2", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8081" },
}
});
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
// Act & Assert
var first = service.GetNextChannelId();
var second = service.GetNextChannelId();
var third = service.GetNextChannelId();
Assert.Equal("ch-1", first);
Assert.Equal("ch-2", second);
Assert.Equal("ch-1", third);
}
[Fact]
public void EgressPoolService_ShouldFallbackToNull_WhenNoChannelsConfigured()
{
// Arrange
var options = Options.Create(new EgressOptions());
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
// Act
var channel = service.GetNextChannelId();
// Assert
Assert.Null(channel);
}
[Fact]
public void EgressPoolService_ShouldCooldown_OnFailures()
{
// Arrange
var options = Options.Create(new EgressOptions
{
Channels = new List<EgressChannelOptions>
{
new() { Id = "ch-1", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8080" },
new() { Id = "ch-2", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8081" },
}
});
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
// Fail ch-1 three times
service.ReportFailure("ch-1", new Exception("Fail 1"));
service.ReportFailure("ch-1", new Exception("Fail 2"));
service.ReportFailure("ch-1", new Exception("Fail 3"));
// Act
// ch-1 is in cooldown, so it should only return ch-2
var first = service.GetNextChannelId();
var second = service.GetNextChannelId();
// Assert
Assert.Equal("ch-2", first);
Assert.Equal("ch-2", second);
}
[Fact]
public async Task RateLimiterService_ShouldLimitPerChannel()
{
// Arrange
var limiter = new RateLimiterService();
// Act
// Set a block penalty for ch-1
limiter.ReportRateLimitExceeded(PlatformType.Polymarket, TimeSpan.FromSeconds(5), "Data", "ch-1");
// Assert
// ch-1 should be limited
var canRequestCh1 = limiter.CanMakeRequest(PlatformType.Polymarket, "Data", "ch-1");
// ch-2 should NOT be limited
var canRequestCh2 = limiter.CanMakeRequest(PlatformType.Polymarket, "Data", "ch-2");
// Global/Default channel should NOT be limited
var canRequestGlobal = limiter.CanMakeRequest(PlatformType.Polymarket, "Data");
Assert.False(canRequestCh1);
Assert.True(canRequestCh2);
Assert.True(canRequestGlobal);
}
}
@@ -52,4 +52,34 @@ public class MarketCategoryMapperTests
var (_, subcategory) = MarketCategoryMapper.Map("", "NBA, Basketball", "Lakers to win?");
Assert.Equal("NBA", subcategory);
}
[Fact]
public void Map_CanonicalTag_WinsOverHeuristics()
{
// "Politics" is a canonical tag, even though it's last in tags, it should map to Politics.
var (category, subcategory) = MarketCategoryMapper.Map("", "Ethiopia, Elections, Politics", "Next PM of Ethiopia?");
Assert.Equal(MarketCategory.Politics, category);
Assert.Equal("Ethiopia", subcategory);
}
[Fact]
public void Map_Subcategory_FiltersNoiseAndCategorySelf()
{
// Category is Sports.
// "Sports" should be skipped as subcategory because it is the category itself.
// "Hide From New" and "2025 Predictions" should be skipped as noise tags.
// "Soccer" should be picked.
var (category, subcategory) = MarketCategoryMapper.Map("", "Hide From New, Sports, 2025 Predictions, Soccer, FIFA World Cup", "Lakers to win?");
Assert.Equal(MarketCategory.Sports, category);
Assert.Equal("Soccer", subcategory);
}
[Fact]
public void Map_Subcategory_EmptyNormalization()
{
// When all tags are noise or category names, subcategory should normalize to empty string.
var (category, subcategory) = MarketCategoryMapper.Map("", "Sports, Hide From New, 2026", "Lakers to win?");
Assert.Equal(MarketCategory.Sports, category);
Assert.Equal(string.Empty, subcategory);
}
}
@@ -0,0 +1,12 @@
using System;
using System.Net.Http;
namespace Predictalytics.Application.Interfaces;
public interface IEgressPoolService
{
string? GetNextChannelId();
HttpMessageInvoker? GetInvoker(string channelId);
void ReportFailure(string channelId, Exception exception);
void ReportSuccess(string channelId);
}
@@ -8,11 +8,11 @@ namespace Predictalytics.Application.Interfaces;
public interface IRateLimiter
{
/// <summary>Wait until a request can be made to the given platform.</summary>
Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default");
Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null);
/// <summary>Check if a request can be made immediately.</summary>
bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default");
bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null);
/// <summary>Report that a 429 Too Many Requests was received.</summary>
void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default");
void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null);
}
@@ -28,10 +28,12 @@ public class RateLimiterService : IRateLimiter
{ "Stake-Default", 1000 }
};
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default")
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null)
{
var key = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default";
var configKey = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(configKey)) configKey = $"{platform}-Default";
var key = string.IsNullOrEmpty(channelId) ? configKey : $"{configKey}-{channelId}";
var sem = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct);
@@ -48,7 +50,7 @@ public class RateLimiterService : IRateLimiter
if (_lastRequest.TryGetValue(key, out var last))
{
var delayMs = Delays.GetValueOrDefault(key, 1000);
var delayMs = Delays.GetValueOrDefault(configKey, 1000);
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
if (elapsed < delayMs)
await Task.Delay((int)(delayMs - elapsed), ct);
@@ -58,23 +60,27 @@ public class RateLimiterService : IRateLimiter
finally { sem.Release(); }
}
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default")
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null)
{
var key = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default";
var configKey = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(configKey)) configKey = $"{platform}-Default";
var key = string.IsNullOrEmpty(channelId) ? configKey : $"{configKey}-{channelId}";
if (_blockedUntil.TryGetValue(key, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
return false;
if (!_lastRequest.TryGetValue(key, out var last)) return true;
var delayMs = Delays.GetValueOrDefault(key, 1000);
var delayMs = Delays.GetValueOrDefault(configKey, 1000);
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs;
}
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default")
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null)
{
var key = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default";
var configKey = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(configKey)) configKey = $"{platform}-Default";
var key = string.IsNullOrEmpty(channelId) ? configKey : $"{configKey}-{channelId}";
var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
_blockedUntil[key] = DateTime.UtcNow.Add(penalty);
@@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace Predictalytics.Infrastructure.Configuration;
public enum EgressChannelType
{
SourceIp,
Proxy
}
public class EgressOptions
{
public List<EgressChannelOptions> Channels { get; set; } = new();
}
public class EgressChannelOptions
{
public string Id { get; set; } = string.Empty;
public EgressChannelType Type { get; set; }
public string Value { get; set; } = string.Empty;
}
@@ -1,5 +1,6 @@
using Predictalytics.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Predictalytics.Infrastructure.Data;
@@ -25,7 +26,49 @@ public class AppDbContext : DbContext
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
private readonly bool _isReadOnly;
public AppDbContext(DbContextOptions<AppDbContext> options, Microsoft.Extensions.Configuration.IConfiguration? configuration = null)
: base(options)
{
_isReadOnly = configuration?.GetValue<bool>("ApiSettings:ReadOnlyDatabase", false) ?? false;
}
public override int SaveChanges()
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChanges();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChangesAsync(cancellationToken);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
protected override void OnModelCreating(ModelBuilder mb)
{
@@ -10,6 +10,7 @@ using Predictalytics.Infrastructure.Providers.Polymarket;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Predictalytics.Infrastructure.Configuration;
namespace Predictalytics.Infrastructure;
@@ -79,6 +80,12 @@ public static class DependencyInjection
services.AddScoped<WatchlistService>();
services.AddSingleton<IRateLimiter, RateLimiterService>();
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
services.AddMemoryCache();
// Egress pool config & services
services.Configure<EgressOptions>(configuration.GetSection("Egress"));
services.AddSingleton<IEgressPoolService, EgressPoolService>();
services.AddTransient<EgressPoolHandler>();
// Platform Providers
services.AddHttpClient();
@@ -89,6 +96,15 @@ public static class DependencyInjection
c.Timeout = TimeSpan.FromSeconds(60);
});
services.AddHttpClient("PolymarketData")
.AddHttpMessageHandler<EgressPoolHandler>();
services.AddHttpClient("PolymarketGamma")
.AddHttpMessageHandler<EgressPoolHandler>();
services.AddHttpClient("PolymarketClob")
.AddHttpMessageHandler<EgressPoolHandler>();
services.AddSingleton<PolymarketApiClient>();
services.AddSingleton<LimitlessApiClient>();
services.AddHttpClient<Predictalytics.Application.Interfaces.IOpenRouterApiClient, Predictalytics.Infrastructure.Providers.OpenRouter.OpenRouterApiClient>();
@@ -109,6 +125,14 @@ public static class DependencyInjection
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
{
using var scope = services.CreateScope();
var config = scope.ServiceProvider.GetService<IConfiguration>();
if (config != null && config.GetValue<bool>("ApiSettings:ReadOnlyDatabase", false))
{
Serilog.Log.Warning("⚠️ EnsureDatabaseAsync: Skipping EF Core migrations and platform seeding because the database is configured as Read-Only.");
return;
}
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
@@ -57,6 +57,45 @@ public static class MarketCategoryMapper
new[] { "fed", "fomc", "cpi", "gdp", "nasdaq", "dow", "ipo" }),
};
private static readonly Dictionary<string, MarketCategory> CanonicalTags = new(StringComparer.OrdinalIgnoreCase)
{
{ "politics", MarketCategory.Politics },
{ "crypto", MarketCategory.Crypto },
{ "sports", MarketCategory.Sports },
{ "pop culture", MarketCategory.PopCulture },
{ "popculture", MarketCategory.PopCulture },
{ "science", MarketCategory.Science },
{ "global news", MarketCategory.GlobalNews },
{ "globalnews", MarketCategory.GlobalNews },
{ "news", MarketCategory.GlobalNews },
{ "economy", MarketCategory.Economy },
{ "business", MarketCategory.Economy },
{ "finance", MarketCategory.Economy }
};
private static readonly HashSet<string> BlacklistTags = new(StringComparer.OrdinalIgnoreCase)
{
"hide from new",
"hide_from_new",
"tournament futures",
"main election",
"recurring",
"exchange",
"overall",
"other",
"none"
};
private static bool IsNoiseTag(string tag)
{
if (string.IsNullOrWhiteSpace(tag)) return true;
var trimmed = tag.Trim();
if (BlacklistTags.Contains(trimmed)) return true;
if (int.TryParse(trimmed, out _)) return true;
if (Regex.IsMatch(trimmed, @"^\d{4}\s+predictions$", RegexOptions.IgnoreCase)) return true;
return false;
}
/// <summary>
/// Classifies a market. The question text is a first-class signal: the Gamma
/// /markets endpoint delivers neither a category field nor event tags, so for
@@ -64,27 +103,60 @@ public static class MarketCategoryMapper
/// </summary>
public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags, string question = "")
{
var tagList = (tags ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
.Where(t => !string.IsNullOrEmpty(t))
.ToList();
if (!string.IsNullOrWhiteSpace(rawCategory))
{
tagList.Insert(0, rawCategory.Trim());
}
// F1: Check canonical tags first over the entire tag set
foreach (var tag in tagList)
{
if (CanonicalTags.TryGetValue(tag, out var canonicalCategory))
{
return (canonicalCategory, GetSubcategory(rawCategory, tags ?? string.Empty, canonicalCategory.ToString()));
}
}
// Fallback: heuristic search on rawCategory + tags + question
var searchString = $"{rawCategory} {tags} {question}".ToLowerInvariant();
var tokens = new HashSet<string>(Regex.Split(searchString, "[^a-z0-9.]+"));
foreach (var rule in Rules)
{
if (rule.Words.Any(tokens.Contains) || rule.Substrings.Any(searchString.Contains))
return (rule.Category, GetSubcategory(rawCategory, tags, rule.Category.ToString()));
return (rule.Category, GetSubcategory(rawCategory, tags ?? string.Empty, rule.Category.ToString()));
}
return (MarketCategory.Other, GetSubcategory(rawCategory, tags, "Other"));
return (MarketCategory.Other, GetSubcategory(rawCategory, tags ?? string.Empty, "Other"));
}
private static string GetSubcategory(string rawCategory, string tags, string fallback)
private static string GetSubcategory(string rawCategory, string tags, string categoryName)
{
if (!string.IsNullOrWhiteSpace(rawCategory) && !rawCategory.Equals("OVERALL", StringComparison.OrdinalIgnoreCase))
return rawCategory.Trim();
var candidates = tags.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
.ToList();
var firstTag = tags.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim();
if (!string.IsNullOrWhiteSpace(firstTag))
return firstTag;
if (!string.IsNullOrWhiteSpace(rawCategory))
{
candidates.Insert(0, rawCategory.Trim());
}
return fallback;
foreach (var c in candidates)
{
if (IsNoiseTag(c)) continue;
// F2: Skip tag if it is the category itself or matches any canonical category tag
if (c.Equals(categoryName, StringComparison.OrdinalIgnoreCase)) continue;
if (CanonicalTags.ContainsKey(c)) continue;
return c;
}
return string.Empty;
}
}
@@ -1,8 +1,10 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Caching.Memory;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Services;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
@@ -16,13 +18,15 @@ public class PolymarketApiClient
private readonly HttpClient _gammaClient;
private readonly HttpClient _clobClient;
private readonly IRateLimiter _rateLimiter;
private readonly IEgressPoolService _egressPool;
private readonly IMemoryCache _cache;
private readonly ILogger<PolymarketApiClient> _logger;
private const string DataApiBase = "https://data-api.polymarket.com";
private const string GammaApiBase = "https://gamma-api.polymarket.com";
private const string ClobApiBase = "https://clob.polymarket.com";
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger)
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, IEgressPoolService egressPool, IMemoryCache cache, ILogger<PolymarketApiClient> logger)
{
_client = httpFactory.CreateClient("PolymarketData");
_client.BaseAddress = new Uri(DataApiBase);
@@ -37,6 +41,8 @@ public class PolymarketApiClient
_clobClient.DefaultRequestHeaders.Add("Accept", "application/json");
_rateLimiter = rateLimiter;
_egressPool = egressPool;
_cache = cache;
_logger = logger;
}
@@ -93,9 +99,42 @@ public class PolymarketApiClient
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
{
var cacheKey = $"market-{conditionId}";
if (_cache.TryGetValue<GammaMarketResponse>(cacheKey, out var cached))
{
return cached;
}
var url = $"/markets?condition_id={conditionId}";
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, "Gamma", ct);
return results?.FirstOrDefault();
var market = results?.FirstOrDefault();
if (market != null)
{
_cache.Set(cacheKey, market, TimeSpan.FromMinutes(30));
}
return market;
}
public async Task<GammaEventResponse?> GetEventAsync(long eventId, CancellationToken ct = default)
{
var cacheKey = $"event-{eventId}";
if (_cache.TryGetValue<GammaEventResponse>(cacheKey, out var cached))
{
return cached;
}
var url = $"/events?id={eventId}";
var results = await ExecuteWithRetryAsync<List<GammaEventResponse>>(_gammaClient, url, "Gamma", ct);
var ev = results?.FirstOrDefault();
if (ev != null)
{
_cache.Set(cacheKey, ev, TimeSpan.FromMinutes(60));
}
return ev;
}
/// <summary>
@@ -156,11 +195,18 @@ public class PolymarketApiClient
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1)
{
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup);
var channelId = _egressPool.GetNextChannelId();
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup, channelId);
try
{
var response = await client.GetAsync(url, ct);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrEmpty(channelId))
{
request.Options.Set(EgressRequestOptions.ChannelIdKey, channelId);
}
var response = await client.SendAsync(request, ct);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
@@ -180,9 +226,9 @@ public class PolymarketApiClient
waitTime = TimeSpan.FromSeconds(30);
}
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group}. Pausing for {WaitTime}s...", endpointGroup, (int)waitTime.TotalSeconds);
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group} (Channel: {Channel}). Pausing for {WaitTime}s...", endpointGroup, channelId ?? "Default", (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup, channelId);
if (attempt < 3)
{
@@ -208,7 +254,7 @@ public class PolymarketApiClient
{
_logger.LogCritical("Unhandled 429 in PolymarketApiClient for {Url}. This should have been caught by the status code check.", url);
}
_logger.LogError(ex, "Failed to fetch from {Url} (attempt {Attempt})", url, attempt);
_logger.LogError(ex, "Failed to fetch from {Url} (attempt {Attempt}, Channel: {Channel})", url, attempt, channelId ?? "Default");
return default;
}
}
@@ -175,7 +175,25 @@ public class PolymarketProvider : IPlatformProvider
if (raw.Events != null && raw.Events.Count > 0)
{
var ev = raw.Events[0];
parentTags = ev.Tags != null ? string.Join(", ", ev.Tags.Select(t => t.Label)) : "";
parentTags = ev.Tags != null && ev.Tags.Count > 0 ? string.Join(", ", ev.Tags.Select(t => t.Label)) : "";
// F3: If parentTags is empty, try to fetch the event from Gamma API to load tags
if (string.IsNullOrEmpty(parentTags) && long.TryParse(ev.Id, out var eventId))
{
try
{
var fetchedEv = await _api.GetEventAsync(eventId, ct);
if (fetchedEv != null && fetchedEv.Tags != null && fetchedEv.Tags.Count > 0)
{
parentTags = string.Join(", ", fetchedEv.Tags.Select(t => t.Label));
ev.Tags = fetchedEv.Tags;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch event {EventId} for tags", eventId);
}
}
}
var market = MapGammaMarket(raw, parentTags);
@@ -0,0 +1,61 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
namespace Predictalytics.Infrastructure.Services;
public static class EgressRequestOptions
{
public static readonly HttpRequestOptionsKey<string> ChannelIdKey = new("EgressChannelId");
}
public class EgressPoolHandler : DelegatingHandler
{
private readonly IEgressPoolService _egressPool;
private readonly ILogger<EgressPoolHandler> _logger;
public EgressPoolHandler(IEgressPoolService egressPool, ILogger<EgressPoolHandler> logger)
{
_egressPool = egressPool;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? channelId = null;
if (request.Options.TryGetValue(EgressRequestOptions.ChannelIdKey, out var cid))
{
channelId = cid;
}
if (string.IsNullOrEmpty(channelId))
{
_logger.LogDebug("🔌 Routing request to {Host} using default channel (fallback)", request.RequestUri?.Host);
return await base.SendAsync(request, cancellationToken);
}
var invoker = _egressPool.GetInvoker(channelId);
if (invoker == null)
{
_logger.LogWarning("🔌 Egress channel '{Id}' not found. Falling back to default channel.", channelId);
return await base.SendAsync(request, cancellationToken);
}
_logger.LogDebug("🔌 Routing request to {Host} using channel '{ChannelId}'", request.RequestUri?.Host, channelId);
try
{
var response = await invoker.SendAsync(request, cancellationToken);
_egressPool.ReportSuccess(channelId);
return response;
}
catch (Exception ex)
{
_egressPool.ReportFailure(channelId, ex);
throw;
}
}
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Predictalytics.Application.Interfaces;
using Predictalytics.Infrastructure.Configuration;
namespace Predictalytics.Infrastructure.Services;
public class EgressPoolService : IEgressPoolService, IDisposable
{
private class ChannelState
{
public EgressChannelOptions Options { get; }
public HttpMessageInvoker Invoker { get; }
public SocketsHttpHandler Handler { get; }
public int ConsecutiveFailures { get; set; }
public DateTime? CooldownUntil { get; set; }
public ChannelState(EgressChannelOptions options, HttpMessageInvoker invoker, SocketsHttpHandler handler)
{
Options = options;
Invoker = invoker;
Handler = handler;
}
}
private readonly List<ChannelState> _channels = new();
private readonly ConcurrentDictionary<string, ChannelState> _channelMap = new();
private readonly ILogger<EgressPoolService> _logger;
private int _roundRobinIndex = -1;
public EgressPoolService(IOptions<EgressOptions> options, ILogger<EgressPoolService> logger)
{
_logger = logger;
InitializeChannels(options.Value);
}
private void InitializeChannels(EgressOptions options)
{
if (options?.Channels == null || options.Channels.Count == 0)
{
_logger.LogInformation("🔌 EgressPoolService: No egress channels configured. Using default direct connection.");
return;
}
foreach (var config in options.Channels)
{
try
{
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
KeepAlivePingDelay = TimeSpan.FromSeconds(15),
KeepAlivePingTimeout = TimeSpan.FromSeconds(5)
};
if (config.Type == EgressChannelType.Proxy)
{
handler.Proxy = new WebProxy(config.Value);
handler.UseProxy = true;
_logger.LogInformation("🔌 Registered Proxy Channel '{Id}' -> {Proxy}", config.Id, config.Value);
}
else if (config.Type == EgressChannelType.SourceIp)
{
var ip = IPAddress.Parse(config.Value);
handler.ConnectCallback = async (context, cancellationToken) =>
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
socket.Bind(new IPEndPoint(ip, 0));
try
{
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
};
_logger.LogInformation("🔌 Registered SourceIp Channel '{Id}' -> {Ip}", config.Id, config.Value);
}
else
{
_logger.LogWarning("🔌 EgressPoolService: Unknown channel type '{Type}' for channel '{Id}'. Skipping.", config.Type, config.Id);
continue;
}
var invoker = new HttpMessageInvoker(handler, disposeHandler: true);
var state = new ChannelState(config, invoker, handler);
_channels.Add(state);
_channelMap[config.Id] = state;
}
catch (Exception ex)
{
_logger.LogError(ex, "🔌 EgressPoolService: Failed to initialize channel '{Id}' ({Type}) with value '{Value}'", config.Id, config.Type, config.Value);
}
}
}
public string? GetNextChannelId()
{
if (_channels.Count == 0) return null;
lock (_channels)
{
var now = DateTime.UtcNow;
// Filter out channels currently in cooldown
var available = _channels
.Where(c => !c.CooldownUntil.HasValue || c.CooldownUntil.Value < now)
.ToList();
if (available.Count == 0)
{
_logger.LogWarning("🔌 All egress channels are currently in cooldown! Falling back to default direct connection.");
return null;
}
_roundRobinIndex = (_roundRobinIndex + 1) % available.Count;
return available[_roundRobinIndex].Options.Id;
}
}
public HttpMessageInvoker? GetInvoker(string channelId)
{
return _channelMap.TryGetValue(channelId, out var state) ? state.Invoker : null;
}
public void ReportFailure(string channelId, Exception exception)
{
if (!_channelMap.TryGetValue(channelId, out var state)) return;
lock (state)
{
state.ConsecutiveFailures++;
_logger.LogWarning("🔌 Channel '{Id}' reported failure ({Count}/3): {Message}", channelId, state.ConsecutiveFailures, exception.Message);
if (state.ConsecutiveFailures >= 3)
{
var cooldown = TimeSpan.FromSeconds(30);
state.CooldownUntil = DateTime.UtcNow.Add(cooldown);
_logger.LogError("🔌 Channel '{Id}' has failed 3 times consecutively. Entering cooldown until {Time}", channelId, state.CooldownUntil);
}
}
}
public void ReportSuccess(string channelId)
{
if (!_channelMap.TryGetValue(channelId, out var state)) return;
lock (state)
{
if (state.ConsecutiveFailures > 0 || state.CooldownUntil.HasValue)
{
state.ConsecutiveFailures = 0;
state.CooldownUntil = null;
_logger.LogInformation("🔌 Channel '{Id}' recovered successfully.", channelId);
}
}
}
public void Dispose()
{
foreach (var state in _channels)
{
state.Invoker.Dispose();
}
_channels.Clear();
_channelMap.Clear();
}
}
@@ -19,6 +19,14 @@ public class AppSettings
[DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false;
private string _egressChannelsText = "";
[Category("Egress (Proxy/IP)")]
[DisplayName("Egress Channels")]
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
private string _dbServer = "localhost";
private string _dbName = "";
private string _dbUser = "";
@@ -38,12 +38,14 @@ public partial class MainForm : Form
{
_webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText;
}
};
_webServer = new EmbeddedWebServer();
_webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText;
// Build Version (Date of compilation/file creation)
try {
@@ -83,6 +85,7 @@ public partial class MainForm : Form
{
_webServer!.ConnectionString = _settings.ConnectionString;
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer!.EgressChannelsText = _settings.EgressChannelsText;
await _webServer!.StartWorkersAsync(_workerCts.Token);
}
catch (OperationCanceledException) { }
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Predictalytics.Api;
using Predictalytics.Api.Endpoints;
using Predictalytics.Worker;
@@ -22,6 +23,7 @@ public class EmbeddedWebServer
private readonly object _lock = new();
public string? ConnectionString { get; set; }
public bool DbConnectionDebug { get; set; }
public string? EgressChannelsText { get; set; }
public async Task UpdateDatabaseAsync()
{
@@ -58,6 +60,29 @@ public class EmbeddedWebServer
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct);
Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled);
// 1b. Offline Category/Subcategory backfill:
// Query all markets that have events with tags in the DB, and re-classify them
var dbMarkets = await db.Markets.Include(m => m.Event).ToListAsync(ct);
int categoryBackfilledCount = 0;
foreach (var m in dbMarkets)
{
if (m.Event != null && !string.IsNullOrEmpty(m.Event.Tags))
{
var (newCategory, newSubcategory) = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(string.Empty, m.Event.Tags, m.Question);
if (m.Category != newCategory || m.Subcategory != newSubcategory)
{
m.Category = newCategory;
m.Subcategory = newSubcategory;
categoryBackfilledCount++;
}
}
}
if (categoryBackfilledCount > 0)
{
await db.SaveChangesAsync(ct);
Log.Information("Backfilled Category/Subcategory for {Count} markets based on Event tags", categoryBackfilledCount);
}
// 2. Wipe derived data computed by earlier engine versions.
var snapshots = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderDailySnapshots;", ct);
var catPerf = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderCategoryPerformances;", ct);
@@ -75,6 +100,7 @@ public class EmbeddedWebServer
var traders = await db.Database.ExecuteSqlRawAsync("UPDATE Traders SET LastAnalyzedAt = NULL;", ct);
var summary = $"ResolutionOutcome backfilled: {backfilled} markets\n" +
$"Category/Subcategory backfilled: {categoryBackfilledCount} markets\n" +
$"Daily snapshots deleted: {snapshots}\n" +
$"Category stats deleted: {catPerf}\n" +
$"Positions reset: {positions}\n" +
@@ -104,11 +130,23 @@ public class EmbeddedWebServer
Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(effectiveConnString ?? "NULL", "Password=[^;]+", "Password=****"));
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug);
if (!string.IsNullOrEmpty(EgressChannelsText))
{
var egressOptions = ParseEgressOptions(EgressChannelsText);
builder.Services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
{
options.Channels = egressOptions.Channels;
});
}
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:5000" };
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
builder.Host.UseSerilog();
var app = builder.Build();
@@ -131,7 +169,8 @@ public class EmbeddedWebServer
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
// Do NOT map endpoints individually here.
app.MapPredictalyticsEndpoints();
app.MapPredictalyticsReadEndpoints();
app.MapPredictalyticsControlEndpoints();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
@@ -174,6 +213,14 @@ public class EmbeddedWebServer
{
Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****"));
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
if (!string.IsNullOrEmpty(EgressChannelsText))
{
var egressOptions = ParseEgressOptions(EgressChannelsText);
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
{
options.Channels = egressOptions.Channels;
});
}
services.AddWorkerServices();
})
.Build();
@@ -286,4 +333,33 @@ public class EmbeddedWebServer
Log.Warning("wwwroot directory not found! Searched: {Paths}", string.Join(", ", candidates));
return null;
}
private Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text)
{
var options = new Predictalytics.Infrastructure.Configuration.EgressOptions();
if (string.IsNullOrWhiteSpace(text)) return options;
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var parts = line.Split('|');
if (parts.Length >= 3)
{
var id = parts[0].Trim();
var typeStr = parts[1].Trim();
var val = parts[2].Trim();
if (Enum.TryParse<Predictalytics.Infrastructure.Configuration.EgressChannelType>(typeStr, true, out var type))
{
options.Channels.Add(new Predictalytics.Infrastructure.Configuration.EgressChannelOptions
{
Id = id,
Type = type,
Value = val
});
}
}
}
return options;
}
}
@@ -28,5 +28,14 @@
"Azuro": {
"EnableCrawling": false
}
},
"Egress": {
"Channels": []
},
"ApiSettings": {
"CanControl": true,
"AuthRequired": false,
"AllowedOrigins": [ "http://localhost:5000" ],
"ReadOnlyDatabase": false
}
}