WebUI Redesign and Component 1: category mapper fixes
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 & 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user