Initial commit: Predictalytics solution

Clean Architecture .NET 8 solution (Domain/Application/Infrastructure/Api/Worker/WinFormsHost)
for analyzing Polymarket traders for copytrading/strategy-replication candidates.

Includes EF Core InitialBaseline migration and DB secrets removed from source/config
in preparation for version control.
This commit is contained in:
Richard
2026-07-01 19:53:29 +02:00
commit afb251acfc
107 changed files with 9613 additions and 0 deletions
@@ -0,0 +1,39 @@
using Predictalytics.Api.Endpoints;
using Predictalytics.Infrastructure;
namespace Predictalytics.Api;
/// <summary>
/// Configures the API WebApplication. Used by the embedded Kestrel server.
/// </summary>
public static class ApiConfiguration
{
public static WebApplication ConfigureApi(WebApplicationBuilder builder, string? connectionStringOverride = null)
{
builder.Services.AddPredictalytics(builder.Configuration, connectionStringOverride);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
var app = builder.Build();
app.UseCors();
app.UseSwagger();
app.UseSwaggerUI();
app.UseDefaultFiles();
app.UseStaticFiles();
// Map endpoints
app.MapDashboardEndpoints();
app.MapTraderEndpoints();
app.MapAlertEndpoints();
app.MapMarketEndpoints();
// Health check
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
return app;
}
}
@@ -0,0 +1,20 @@
using Predictalytics.Application.Interfaces;
namespace Predictalytics.Api.Endpoints;
public static class AlertEndpoints
{
public static void MapAlertEndpoints(this WebApplication app)
{
var group = app.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)));
group.MapPut("/{id:int}/read", async (int id, IAlertService svc, CancellationToken ct) =>
{
await svc.MarkAsReadAsync(id, ct);
return Results.Ok();
});
}
}
@@ -0,0 +1,13 @@
using Predictalytics.Application.Interfaces;
namespace Predictalytics.Api.Endpoints;
public static class DashboardEndpoints
{
public static void MapDashboardEndpoints(this WebApplication app)
{
app.MapGet("/api/dashboard", async (IAnalyticsService svc, CancellationToken ct) =>
Results.Ok(await svc.GetDashboardAsync(ct)))
.WithTags("Dashboard");
}
}
@@ -0,0 +1,23 @@
using Predictalytics.Application.Interfaces;
namespace Predictalytics.Api.Endpoints;
public static class MarketEndpoints
{
public static void MapMarketEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/markets").WithTags("Markets");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, CancellationToken ct) =>
{
var result = await svc.GetMarketsAsync(skip ?? 0, take ?? 50, platform, ct);
return Results.Ok(result);
});
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var detail = await svc.GetMarketDetailAsync(id, ct);
return detail is not null ? Results.Ok(detail) : Results.NotFound();
});
}
}
@@ -0,0 +1,19 @@
using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints;
public static class SearchEndpoints
{
public static void MapSearchEndpoints(this WebApplication app)
{
app.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);
return Results.Ok(results);
}).WithTags("Search");
}
}
@@ -0,0 +1,44 @@
using Predictalytics.Application.Interfaces;
namespace Predictalytics.Api.Endpoints;
public static class TraderEndpoints
{
public static void MapTraderEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, ct)));
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var detail = await svc.GetTraderDetailAsync(id, ct);
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.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}/refresh", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
await svc.TriggerTradeSyncAsync(id, ct);
return Results.Ok();
});
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{
var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id });
});
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Api</RootNamespace>
<!-- This project is used as a library by WinFormsHost; it does not run standalone -->
<OutputType>Library</OutputType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"profiles": {
"Predictalytics.Api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62273;http://localhost:62274"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
@@ -0,0 +1,453 @@
/* ==========================================================================
Predictalytics — Premium SaaS Dashboard Design System
Modern clean dashboard inspired by Dribbble reference design.
========================================================================== */
/* ─── CSS Custom Properties (Design Tokens) ─── */
:root {
/* Light Theme */
--bg: #F4F5F7;
--bg-sidebar: #FFFFFF;
--bg-card: #FFFFFF;
--bg-input: #F0F1F3;
--text-primary: #111111;
--text-secondary: #666666;
--text-muted: #999999;
--border: #E8E9EC;
--accent: #FF2D55;
--accent-glow: rgba(255, 45, 85, 0.15);
--success: #34C759;
--danger: #FF3B30;
--warning: #FF9500;
--info: #5AC8FA;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.08);
--radius: 12px;
--radius-sm: 8px;
--transition: 0.2s ease;
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--sidebar-w: 240px;
}
[data-theme="dark"] {
--bg: #0B0C0F;
--bg-sidebar: #111215;
--bg-card: #16181D;
--bg-input: #1E2028;
--text-primary: #EEEEEE;
--text-secondary: #AAAAAA;
--text-muted: #666666;
--border: #2A2C33;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2);
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.4);
}
/* ─── Reset & Base ─── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text-primary);
display: flex;
min-height: 100vh;
transition: background var(--transition), color var(--transition);
}
/* ─── Sidebar ─── */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
transition: background var(--transition), border-color var(--transition);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 20px;
border-bottom: 1px solid var(--border);
}
.logo-icon {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), #FF6B8A);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
color: #fff;
font-weight: 800;
font-size: 18px;
box-shadow: 0 2px 12px var(--accent-glow);
}
.logo-text { font-weight: 700; font-size: 16px; letter-spacing: -0.3px; }
.sidebar-nav { flex: 1; padding: 12px 10px; display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
font-size: 13.5px;
transition: all var(--transition);
position: relative;
}
.nav-item:hover { background: var(--bg-input); color: var(--text-primary); }
.nav-item.active {
background: var(--accent-glow);
color: var(--accent);
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -10px;
top: 50%; transform: translateY(-50%);
width: 3px; height: 20px;
background: var(--accent);
border-radius: 2px;
}
.badge {
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
margin-left: auto;
}
.sidebar-footer { padding: 12px 10px; border-top: 1px solid var(--border); }
/* ─── Main Content ─── */
.main-content {
flex: 1;
margin-left: var(--sidebar-w);
min-height: 100vh;
}
/* ─── Topbar ─── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(12px);
transition: background var(--transition), border-color var(--transition);
}
.topbar-left { flex: 1; max-width: 600px; display: flex; align-items: center; gap: 12px; }
.platform-select {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
outline: none;
cursor: pointer;
font-family: inherit;
transition: var(--transition);
}
.platform-select:focus { border-color: var(--accent); }
.search-box {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 14px;
transition: border-color var(--transition);
flex: 1;
min-width: 400px;
}
.search-box:focus-within { border-color: var(--accent); }
.search-box input {
border: none;
background: none;
outline: none;
color: var(--text-primary);
font-family: var(--font);
font-size: 13px;
width: 100%;
}
.search-box input::placeholder { color: var(--text-muted); }
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
.topbar-right { display: flex; align-items: center; gap: 16px; }
.timeframe-toggle {
display: flex;
background: var(--bg-input);
border-radius: var(--radius-sm);
padding: 3px;
}
.tf-btn {
padding: 6px 14px;
border: none;
background: none;
color: var(--text-secondary);
font-family: var(--font);
font-size: 12px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all var(--transition);
}
.tf-btn.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
.tf-btn:hover:not(.active) { color: var(--text-primary); }
/* Theme Toggle */
.theme-toggle {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
cursor: pointer;
background: var(--bg-input);
border: 1px solid var(--border);
transition: all var(--transition);
color: var(--text-secondary);
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
[data-theme="dark"] .icon-sun { display: block; }
[data-theme="dark"] .icon-moon { display: none; }
[data-theme="light"] .icon-sun { display: none; }
[data-theme="light"] .icon-moon { display: block; }
.icon-sun { display: none; }
/* ─── Page Content ─── */
.page-content { padding: 28px 32px; }
.page { display: none; }
.page.active { display: block; animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.page-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
letter-spacing: -0.5px;
}
/* ─── Metric Cards ─── */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
transition: all var(--transition);
box-shadow: var(--shadow-sm);
}
.metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.metric-card.accent {
background: linear-gradient(135deg, var(--accent), #FF6B8A);
border: none;
color: #fff;
}
.metric-card.accent .metric-label { color: rgba(255,255,255,0.8); }
.metric-card.accent .metric-delta { color: rgba(255,255,255,0.9); }
.metric-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.metric-value { font-size: 28px; font-weight: 800; letter-spacing: -1px; margin-bottom: 4px; }
.metric-delta { font-size: 12px; font-weight: 600; }
.metric-delta.positive { color: var(--success); }
.metric-delta.negative { color: var(--danger); }
/* ─── Cards ─── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
transition: all var(--transition);
overflow: hidden;
}
.card-header {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
/* ─── Charts ─── */
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.chart-card { margin-bottom: 0; }
.chart-container { padding: 20px 24px; height: 260px; }
/* ─── Data Tables ─── */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table td {
padding: 12px 16px;
font-size: 13px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table tbody tr { transition: background var(--transition); }
.data-table tbody tr:hover { background: var(--bg-input); }
.data-table tbody tr:last-child td { border-bottom: none; }
/* Badges in tables */
.tier-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.tier-diamond { background: rgba(99,102,241,0.15); color: #818CF8; }
.tier-platinum { background: rgba(168,85,247,0.15); color: #C084FC; }
.tier-gold { background: rgba(245,158,11,0.15); color: #F59E0B; }
.tier-silver { background: rgba(156,163,175,0.15); color: #9CA3AF; }
.tier-bronze { background: rgba(180,83,9,0.15); color: #D97706; }
.tier-unknown { background: var(--bg-input); color: var(--text-muted); }
.side-buy { color: var(--success); font-weight: 700; }
.side-sell { color: var(--danger); font-weight: 700; }
.pnl-positive { color: var(--success); font-weight: 600; }
.pnl-negative { color: var(--danger); font-weight: 600; }
.btn-sm {
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-input);
color: var(--text-secondary);
font-family: var(--font);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
}
.btn-sm:hover { border-color: var(--accent); color: var(--accent); }
/* ─── Alerts ─── */
.alert-item {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
}
.alert-item:hover { background: var(--bg-input); }
.alert-item:last-child { border-bottom: none; }
.alert-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; }
.alert-severity-4 { background: rgba(255,59,48,0.15); }
.alert-severity-3 { background: rgba(255,149,0,0.15); }
.alert-severity-2 { background: rgba(90,200,250,0.15); }
.alert-severity-1 { background: var(--bg-input); }
.alert-content { flex: 1; }
.alert-title { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
.alert-message { font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
.alert-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; margin-top: 2px; }
.alert-unread .alert-title::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: 8px; }
/* ─── Empty State ─── */
.empty-state {
padding: 60px 24px;
text-align: center;
color: var(--text-muted);
}
.empty-state p { font-size: 14px; margin-top: 8px; }
/* ─── Responsive ─── */
@media (max-width: 1200px) { .metrics-grid { grid-template-columns: repeat(2, 1fr); } .charts-row { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .sidebar { display: none; } .main-content { margin-left: 0; } .metrics-grid { grid-template-columns: 1fr; } }
/* ─── Detail Views ─── */
.detail-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.btn-back { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 14px; font-weight: 600; padding: 4px 8px; border-radius: 4px; transition: var(--transition); }
.btn-back:hover { background: var(--bg-input); color: var(--text-primary); }
.detail-grid { display: grid; grid-template-columns: 300px 1fr; gap: 24px; align-items: start; }
.detail-sidebar { padding: 24px; }
.stat-group { margin-bottom: 16px; }
.stat-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; }
.stat-value { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.stat-value.small { font-size: 12px; font-family: 'Cascadia Code', monospace; word-break: break-all; opacity: 0.8; }
.outcome-row { margin-bottom: 12px; padding: 12px; background: var(--bg-input); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }
.outcome-row span { font-size: 13px; font-weight: 600; }
.outcome-row strong { font-size: 15px; color: var(--accent); }
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-top: 4px; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s ease; }
.market-img-container img { width: 100%; height: auto; border-radius: 8px; margin-bottom: 16px; box-shadow: var(--shadow-sm); }
.outcomes-list { padding: 16px 24px; }
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics">
<title>Predictalytics</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<div class="logo-icon">P</div>
<span class="logo-text">Predictalytics</span>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-page="dashboard">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span>Dashboard</span>
</a>
<a href="#" class="nav-item" data-page="traders">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Traders</span>
</a>
<a href="#" class="nav-item" data-page="markets">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<span>Markets</span>
</a>
<a href="#" class="nav-item" data-page="alerts">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span>Alerts</span>
<span class="badge" id="alertBadge" style="display:none">0</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/swagger" target="_blank" class="nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>API Docs</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<select id="platformSelect" class="platform-select">
<option value="All">All Platforms</option>
<option value="Polymarket">Polymarket</option>
<option value="Limitless">Limitless</option>
<option value="Azuro">Azuro</option>
</select>
<div class="search-box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="Search traders, markets..." autocomplete="off">
</div>
<select id="sortSelect" class="platform-select">
<option value="default">Sort by: Default</option>
<option value="score">Sort by: Score/Vol</option>
<option value="name">Sort by: Name/Date</option>
<option value="pnl">Sort by: PnL/Liq</option>
</select>
</div>
<div class="topbar-right">
<div class="timeframe-toggle">
<button class="tf-btn active" data-tf="24h">24H</button>
<button class="tf-btn" data-tf="7d">7D</button>
<button class="tf-btn" data-tf="30d">30D</button>
</div>
<div class="theme-toggle" id="themeToggle" title="Toggle Dark/Light Mode">
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</div>
</div>
</header>
<!-- Page Content -->
<div class="page-content" id="pageContent">
<!-- Dashboard Page -->
<section class="page active" id="page-dashboard">
<h1 class="page-title">Dashboard</h1>
<!-- Metric Cards -->
<div class="metrics-grid" id="metricsGrid">
<div class="metric-card"><div class="metric-label">Total Traders</div><div class="metric-value" id="metricTraders"></div><div class="metric-delta positive">tracking</div></div>
<div class="metric-card"><div class="metric-label">Active (24h)</div><div class="metric-value" id="metricActive"></div><div class="metric-delta positive">live</div></div>
<div class="metric-card"><div class="metric-label">Total Trades</div><div class="metric-value" id="metricTrades"></div><div class="metric-delta">all time</div></div>
<div class="metric-card accent"><div class="metric-label">Volume (24h)</div><div class="metric-value" id="metricVolume"></div><div class="metric-delta positive">USD</div></div>
</div>
<!-- Charts Row -->
<div class="charts-row">
<div class="card chart-card">
<div class="card-header"><h2>Platform Breakdown</h2></div>
<div class="chart-container"><canvas id="platformChart"></canvas></div>
</div>
<div class="card chart-card">
<div class="card-header"><h2>Trader Tiers</h2></div>
<div class="chart-container"><canvas id="tierChart"></canvas></div>
</div>
</div>
<!-- Top Traders Table -->
<div class="card">
<div class="card-header"><h2>Top Traders</h2></div>
<div class="table-wrap">
<table class="data-table" id="topTradersTable">
<thead><tr><th>#</th><th>Trader</th><th>Platform</th><th>Score</th><th>Win Rate</th><th>PnL</th><th>Tier</th><th>Trades</th></tr></thead>
<tbody id="topTradersBody"></tbody>
</table>
</div>
</div>
<!-- Recent Trades -->
<div class="card">
<div class="card-header"><h2>Recent Trades</h2></div>
<div class="table-wrap">
<table class="data-table" id="recentTradesTable">
<thead><tr><th>Time</th><th>Trader</th><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Amount</th></tr></thead>
<tbody id="recentTradesBody"></tbody>
</table>
</div>
</div>
</section>
<!-- Traders Page -->
<section class="page" id="page-traders">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<h1 class="page-title" style="margin-bottom:0">Traders</h1>
<div class="card" style="margin-bottom:0; padding:12px 20px; display:flex; gap:12px; align-items:center;">
<span style="font-size:13px; font-weight:600">Add Trader:</span>
<select id="addPlatform" class="platform-select">
<option value="Polymarket">Polymarket</option>
<option value="Limitless">Limitless</option>
</select>
<input type="text" id="addWallet" placeholder="Wallet Address" class="platform-select" style="width:300px">
<button class="btn-sm" onclick="manualAddTrader()" style="padding:6px 16px; background:var(--accent); color:white; border:none">Add</button>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table class="data-table"><thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Score</th><th>Win Rate</th><th>PnL</th><th>Tier</th><th>Strategy</th><th>Actions</th></tr></thead>
<tbody id="allTradersBody"></tbody>
</table>
</div>
</div>
</section>
<!-- Markets Page -->
<section class="page" id="page-markets"><h1 class="page-title">Markets</h1><div class="card"><div class="table-wrap"><table class="data-table"><thead><tr><th>Platform</th><th>Question</th><th>Volume</th><th>Liquidity</th><th>End Date</th><th>Status</th></tr></thead><tbody id="allMarketsBody"></tbody></table></div></div></section>
<!-- Alerts Page -->
<section class="page" id="page-alerts"><h1 class="page-title">Alerts</h1><div class="card" id="alertsList"></div></section>
<!-- Search Results Page -->
<section class="page" id="page-search">
<h1 class="page-title" id="search-title">Search Results</h1>
<div id="searchResults">
<div class="card">
<div class="card-header"><h2>Traders</h2></div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Score</th><th>Tier</th><th>Actions</th></tr></thead>
<tbody id="searchTradersBody"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><h2>Markets</h2></div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Platform</th><th>Question</th><th>Volume</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="searchMarketsBody"></tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Trader Detail Page -->
<section class="page" id="page-trader-detail">
<div class="detail-header" style="justify-content:space-between">
<div style="display:flex; align-items:center; gap:16px">
<button class="btn-back" onclick="navigateBack()">← Back</button>
<h1 class="page-title" id="td-name" style="margin-bottom:0">Trader Name</h1>
</div>
<button class="btn-sm" id="btn-refresh-trader" style="padding:8px 16px; background:var(--bg-input)">Sync History</button>
</div>
<div class="detail-grid">
<div class="card detail-sidebar">
<div class="stat-group">
<div class="stat-label">Platform</div>
<div class="stat-value" id="td-platform"></div>
</div>
<div class="stat-group">
<div class="stat-label">Platform ID</div>
<div class="stat-value small" id="td-platformId"></div>
</div>
<div class="stat-group">
<div class="stat-label">Tier</div>
<div class="stat-value" id="td-tier"></div>
</div>
<div class="stat-group">
<div class="stat-label">Strategy</div>
<div class="stat-value" id="td-strategy"></div>
</div>
</div>
<div class="detail-main">
<div class="metrics-grid">
<div class="metric-card"><div class="metric-label">Win Rate</div><div class="metric-value" id="td-winrate"></div></div>
<div class="metric-card"><div class="metric-label">Total PnL</div><div class="metric-value" id="td-pnl"></div></div>
<div class="metric-card"><div class="metric-label">Total Trades</div><div class="metric-value" id="td-trades"></div></div>
<div class="metric-card accent"><div class="metric-label">Score</div><div class="metric-value" id="td-score"></div></div>
</div>
<div class="card">
<div class="card-header"><h2>Recent Trades</h2></div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Time</th><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Amount</th></tr></thead>
<tbody id="td-tradesBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- Market Detail Page -->
<section class="page" id="page-market-detail">
<div class="detail-header">
<button class="btn-back" onclick="navigateBack()">← Back</button>
<h1 class="page-title" id="md-question">Market Question</h1>
</div>
<div class="detail-grid">
<div class="card detail-sidebar">
<div id="md-image" class="market-img-container"></div>
<div class="stat-group">
<div class="stat-label">Platform</div>
<div class="stat-value" id="md-platform"></div>
</div>
<div class="stat-group">
<div class="stat-label">Category</div>
<div class="stat-value" id="md-category"></div>
</div>
<div class="stat-group">
<div class="stat-label">Ends</div>
<div class="stat-value" id="md-ends"></div>
</div>
</div>
<div class="detail-main">
<div class="metrics-grid">
<div class="metric-card"><div class="metric-label">Volume</div><div class="metric-value" id="md-volume"></div></div>
<div class="metric-card"><div class="metric-label">Liquidity</div><div class="metric-value" id="md-liquidity"></div></div>
<div class="metric-card"><div class="metric-label">Status</div><div class="metric-value" id="md-status"></div></div>
</div>
<div class="card">
<div class="card-header"><h2>Outcomes</h2></div>
<div class="outcomes-list" id="md-outcomes"></div>
</div>
<div class="card">
<div class="card-header"><h2>Recent Trades</h2></div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Time</th><th>Trader</th><th>Side</th><th>Price</th><th>Size</th><th>Amount</th></tr></thead>
<tbody id="md-tradesBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
<script src="js/app.js"></script>
</body>
</html>
+430
View File
@@ -0,0 +1,430 @@
// Predictalytics Analytics — Dashboard Application
const API_BASE = '';
// ─── Theme Toggle ───
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
const savedTheme = localStorage.getItem('ba-theme') || 'dark';
html.setAttribute('data-theme', savedTheme);
themeToggle.addEventListener('click', () => {
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('ba-theme', next);
updateChartColors();
});
// ─── Navigation ───
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
item.addEventListener('click', e => {
e.preventDefault();
const page = item.dataset.page;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(`page-${page}`).classList.add('active');
if (page === 'traders') loadTraders();
if (page === 'alerts') loadAlerts();
if (page === 'markets') loadMarkets();
});
});
let pageHistory = ['dashboard'];
function navigateTo(pageId) {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const page = document.getElementById(`page-${pageId}`);
if (page) {
page.classList.add('active');
pageHistory.push(pageId);
}
}
function navigateBack() {
if (pageHistory.length > 1) {
pageHistory.pop();
const prev = pageHistory[pageHistory.length - 1];
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(`page-${prev}`).classList.add('active');
}
}
// ─── Search ───
const searchInput = document.getElementById('searchInput');
searchInput?.addEventListener('keypress', e => {
if (e.key === 'Enter') {
const query = searchInput.value.trim();
if (query) performSearch(query);
}
});
async function performSearch(query) {
navigateTo('search');
document.getElementById('search-title').textContent = `Search Results for "${query}"`;
const data = await api(`/api/search?q=${encodeURIComponent(query)}`);
const tBody = document.getElementById('searchTradersBody');
const mBody = document.getElementById('searchMarketsBody');
if (!data) return;
tBody.innerHTML = data.traders.map((t, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${t.displayName}</strong></td>
<td>${t.platform}</td>
<td>${Number(t.combinedScore).toFixed(1)}</td>
<td>${fmt.tier(t.tier)}</td>
<td><button class="btn-sm" onclick="viewTrader(${t.id})">View</button></td>
</tr>
`).join('') || '<tr><td colspan="6">No traders found</td></tr>';
mBody.innerHTML = data.markets.map(m => `
<tr>
<td>${m.platform}</td>
<td title="${m.question}">${m.question.substring(0, 50)}...</td>
<td>${fmt.usd(m.volume)}</td>
<td>${m.isResolved ? 'Resolved' : 'Active'}</td>
<td><button class="btn-sm" onclick="viewMarket(${m.id})">View</button></td>
</tr>
`).join('') || '<tr><td colspan="5">No markets found</td></tr>';
}
async function manualAddTrader() {
const platform = document.getElementById('addPlatform').value;
const wallet = document.getElementById('addWallet').value.trim();
if (!wallet) return;
const res = await fetch(`/api/traders?platform=${platform}&wallet=${wallet}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
alert('Trader added successfully! Sync will start shortly.');
document.getElementById('addWallet').value = '';
viewTrader(data.id);
} else {
alert('Failed to add trader.');
}
}
async function manualUpdateTrader(id) {
const res = await fetch(`/api/traders/${id}/refresh`, { method: 'POST' });
if (res.ok) {
alert('Sync triggered manually. Data will update in a few minutes.');
} else {
alert('Failed to trigger sync.');
}
}
let currentPlatform = 'All';
let currentSort = 'default';
document.getElementById('platformSelect')?.addEventListener('change', (e) => {
currentPlatform = e.target.value;
refreshActivePage();
});
document.getElementById('sortSelect')?.addEventListener('change', (e) => {
currentSort = e.target.value;
refreshActivePage();
});
function refreshActivePage() {
const activePage = document.querySelector('.page.active')?.id;
if (activePage === 'page-dashboard') loadDashboard();
else if (activePage === 'page-traders') loadTraders();
else if (activePage === 'page-markets') loadMarkets();
}
// ─── API Helpers ───
async function api(endpoint) {
try {
const res = await fetch(`${API_BASE}${endpoint}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error(`API Error [${endpoint}]:`, err);
return null;
}
}
// ─── Format Helpers ───
const fmt = {
usd: v => { if (v === null || v === undefined) return '$0'; const n = Number(v); return n >= 1000000 ? `$${(n/1000000).toFixed(1)}M` : n >= 1000 ? `$${(n/1000).toFixed(1)}K` : `$${n.toFixed(0)}`; },
pct: v => { if (v === null || v === undefined) return '0%'; return `${Number(v).toFixed(1)}%`; },
num: v => { if (v === null || v === undefined) return '0'; return Number(v).toLocaleString(); },
time: v => { if (!v) return '—'; const d = new Date(v); const now = new Date(); const diff = (now - d) / 1000;
if (diff < 60) return `${Math.floor(diff)}s ago`;
if (diff < 3600) return `${Math.floor(diff/60)}m ago`;
if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;
return d.toLocaleDateString(); },
tier: t => { if (!t) return '—'; const cls = `tier-${t.toString().toLowerCase()}`; return `<span class="tier-badge ${cls}">${t}</span>`; },
side: s => { if (!s) return '—'; return `<span class="side-${s.toString().toLowerCase()}">${s}</span>`; },
pnl: v => { if (v === null || v === undefined) return '$0'; const n = Number(v); return `<span class="${n >= 0 ? 'pnl-positive' : 'pnl-negative'}">${fmt.usd(Math.abs(n))}${n >= 0 ? ' ▲' : ' ▼'}</span>`; }
};
// ─── Chart instances ───
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'
};
}
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();
});
}
// ─── Dashboard Load ───
async function loadDashboard() {
const data = await api('/api/dashboard');
if (!data) {
document.getElementById('metricTraders').textContent = '0';
document.getElementById('metricActive').textContent = '0';
document.getElementById('metricTrades').textContent = '0';
document.getElementById('metricVolume').textContent = '$0';
return;
}
// Metrics
document.getElementById('metricTraders').textContent = fmt.num(data.totalTraders);
document.getElementById('metricActive').textContent = fmt.num(data.activeTraders24h);
document.getElementById('metricTrades').textContent = fmt.num(data.totalTrades);
document.getElementById('metricVolume').textContent = fmt.usd(data.totalVolume24h);
// Alert badge
const badge = document.getElementById('alertBadge');
if (data.unreadAlerts > 0) { badge.textContent = data.unreadAlerts; badge.style.display = 'inline'; }
else { badge.style.display = 'none'; }
// Top Traders table
const tbody = document.getElementById('topTradersBody');
tbody.innerHTML = data.topTraders.map((t, i) => `
<tr onclick="viewTrader(${t.id})" style="cursor:pointer">
<td>${i + 1}</td>
<td><strong>${t.displayName}</strong></td>
<td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td>
<td>${fmt.tier(t.tier)}</td>
<td>${fmt.num(t.totalTrades)}</td>
</tr>
`).join('');
// Recent Trades table
const rBody = document.getElementById('recentTradesBody');
rBody.innerHTML = data.recentTrades.map(t => `
<tr>
<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.dbMarketId ? `<span style="color:var(--primary)">Market #${t.dbMarketId}</span>` : (t.marketId ? t.marketId.substring(0, 12) + '...' : '—')}
</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>
</tr>
`).join('');
// Platform Chart
const cc = getChartColors();
const pLabels = Object.keys(data.platformBreakdown.traderCounts);
const pData = Object.values(data.platformBreakdown.traderCounts);
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'],
borderWidth: 0 }] },
options: { responsive: true, maintainAspectRatio: false, cutout: '70%',
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Inter'", 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 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 }] },
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'" } } } },
plugins: { legend: { display: false } } }
});
}
// ─── Traders Page ───
async function loadTraders() {
let url = '/api/traders?skip=0&take=100';
if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`;
let data = await api(url);
const tbody = document.getElementById('allTradersBody');
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
// Sorting
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
else if (currentSort === 'name') data.sort((a, b) => a.displayName.localeCompare(b.displayName));
else if (currentSort === 'pnl') data.sort((a, b) => b.totalPnl - a.totalPnl);
tbody.innerHTML = data.map((t, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${t.displayName}</strong></td>
<td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td>
<td>${fmt.tier(t.tier)}</td>
<td>${t.strategy}</td>
<td><button class="btn-sm" onclick="viewTrader(${t.id})">Details</button></td>
</tr>
`).join('');
}
// ─── Alerts Page ───
async function loadAlerts() {
const data = await api('/api/alerts?count=50');
const el = document.getElementById('alertsList');
if (!data || !data.length) { el.innerHTML = '<div class="empty-state"><p>No alerts yet.</p></div>'; return; }
el.innerHTML = data.map(a => `
<div class="alert-item ${a.isRead ? '' : 'alert-unread'}">
<div class="alert-icon alert-severity-${a.severity}">🔔</div>
<div class="alert-content">
<div class="alert-title">${a.title}</div>
<div class="alert-message">${a.message}</div>
</div>
<div class="alert-time">${fmt.time(a.createdAt)}</div>
</div>
`).join('');
}
// ─── Markets Page ───
async function loadMarkets() {
let url = '/api/markets?skip=0&take=100';
if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`;
let data = await api(url);
const tbody = document.getElementById('allMarketsBody');
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><p>No markets found.</p></div></td></tr>'; return; }
// Sorting
if (currentSort === 'score') data.sort((a, b) => b.volume - a.volume);
else if (currentSort === 'name') data.sort((a, b) => (a.endDate || '').localeCompare(b.endDate || ''));
else if (currentSort === 'pnl') data.sort((a, b) => b.liquidity - a.liquidity);
tbody.innerHTML = data.map(m => `
<tr onclick="viewMarket(${m.id})" style="cursor:pointer">
<td>${m.platform}</td>
<td title="${m.question}"><strong>${m.question.length > 60 ? m.question.substring(0, 60) + '...' : m.question}</strong></td>
<td>${fmt.usd(m.volume)}</td>
<td>${fmt.usd(m.liquidity)}</td>
<td>${m.endDate ? new Date(m.endDate).toLocaleDateString() : '—'}</td>
<td>${m.isResolved ? '<span class="side-sell">Resolved</span>' : '<span class="side-buy">Active</span>'}</td>
</tr>
`).join('');
}
async function viewTrader(id) {
navigateTo('trader-detail');
const t = await api(`/api/traders/${id}`);
if (!t) return;
document.getElementById('td-name').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;
document.getElementById('td-winrate').textContent = fmt.pct(t.winRate);
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
document.getElementById('td-trades').textContent = fmt.num(t.totalTrades);
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
const refreshBtn = document.getElementById('btn-refresh-trader');
refreshBtn.onclick = () => manualUpdateTrader(id);
const tbody = document.getElementById('td-tradesBody');
tbody.innerHTML = t.recentTrades.map(tr => `
<tr>
<td>${fmt.time(tr.executedAt)}</td>
<td onclick="${tr.dbMarketId ? `viewMarket(${tr.dbMarketId})` : `''`}" style="cursor:${tr.dbMarketId ? 'pointer' : 'default'}; color:${tr.dbMarketId ? 'var(--primary)' : 'inherit'}" title="Market ID: ${tr.marketId}">
${tr.dbMarketId ? `Market #${tr.dbMarketId}` : (tr.marketId ? tr.marketId.substring(0, 16) + '...' : '—')}
</td>
<td>${fmt.side(tr.side)}</td>
<td>${Number(tr.price).toFixed(2)}</td>
<td>${fmt.num(tr.size)}</td>
<td>${fmt.usd(tr.amount)}</td>
</tr>
`).join('');
}
async function viewMarket(id) {
navigateTo('market-detail');
const m = await api(`/api/markets/${id}`);
if (!m) return;
document.getElementById('md-question').textContent = m.question;
document.getElementById('md-platform').textContent = m.platform;
document.getElementById('md-category').textContent = m.category;
document.getElementById('md-ends').textContent = m.endDate ? new Date(m.endDate).toLocaleDateString() : 'Never';
document.getElementById('md-volume').textContent = fmt.usd(m.volume);
document.getElementById('md-liquidity').textContent = fmt.usd(m.liquidity);
document.getElementById('md-status').textContent = m.isResolved ? 'Resolved' : 'Active';
const imgContainer = document.getElementById('md-image');
if (m.imageUrl) imgContainer.innerHTML = `<img src="${m.imageUrl}" alt="Market" style="width:100%; border-radius:8px; margin-bottom:16px;">`;
else imgContainer.innerHTML = '';
const oList = document.getElementById('md-outcomes');
oList.innerHTML = m.outcomes.map(o => `
<div class="outcome-row">
<span>${o.name}</span>
<strong>${(o.price * 100).toFixed(1)}¢</strong>
<div class="progress-bar"><div class="progress-fill" style="width:${o.price * 100}%"></div></div>
</div>
`).join('');
const tbody = document.getElementById('md-tradesBody');
tbody.innerHTML = m.recentTrades.map(tr => `
<tr>
<td>${fmt.time(tr.executedAt)}</td>
<td onclick="viewTrader(${tr.traderId})" style="cursor:pointer; color:var(--primary)">${tr.traderName}</td>
<td>${fmt.side(tr.side)}</td>
<td>${Number(tr.price).toFixed(2)}</td>
<td>${fmt.num(tr.size)}</td>
<td>${fmt.usd(tr.amount)}</td>
</tr>
`).join('');
}
async function viewMarketByQuestion(question) {
// Legacy helper: search markets by question text
const data = await api(`/api/search?q=${encodeURIComponent(question)}`);
if (data && data.markets.length > 0) {
viewMarket(data.markets[0].id);
}
}
function viewMarketById(dbMarketId) {
if (dbMarketId) viewMarket(dbMarketId);
}
// ─── Initial Load ───
loadDashboard();
// Auto-refresh every 30 seconds
setInterval(() => {
const activePage = document.querySelector('.page.active');
if (activePage?.id === 'page-dashboard') loadDashboard();
}, 30000);
@@ -0,0 +1,31 @@
namespace Predictalytics.Application.DTOs;
public record DashboardDto(
int TotalTraders,
int ActiveTraders24h,
int TotalTrades,
decimal TotalVolume24h,
int UnreadAlerts,
int WatchlistCount,
IReadOnlyList<TraderDto> TopTraders,
IReadOnlyList<TradeDto> RecentTrades,
IReadOnlyList<AlertDto> RecentAlerts,
PlatformBreakdownDto PlatformBreakdown
);
public record PlatformBreakdownDto(
Dictionary<string, int> TraderCounts,
Dictionary<string, decimal> VolumeCounts
);
public record AlertDto(
int Id,
string Type,
string Platform,
string Title,
string Message,
int Severity,
bool IsRead,
DateTime CreatedAt,
string? TraderName
);
@@ -0,0 +1,25 @@
namespace Predictalytics.Application.DTOs;
public class MarketDetailDto
{
public int Id { get; set; }
public string Platform { get; set; } = "";
public string PlatformMarketId { get; set; } = "";
public string Question { get; set; } = "";
public string? Description { get; set; }
public string Category { get; set; } = "";
public double Volume { get; set; }
public double Liquidity { get; set; }
public DateTime? EndDate { get; set; }
public bool IsResolved { get; set; }
public string? ResolutionOutcome { get; set; }
public string? ImageUrl { get; set; }
public IReadOnlyList<MarketOutcomeDto> Outcomes { get; set; } = new List<MarketOutcomeDto>();
public IReadOnlyList<TradeDto> RecentTrades { get; set; } = new List<TradeDto>();
}
public class MarketOutcomeDto
{
public string Name { get; set; } = "";
public double Price { get; set; }
}
@@ -0,0 +1,12 @@
namespace Predictalytics.Application.DTOs;
public class MarketDto
{
public int Id { get; set; }
public string Platform { get; set; } = "";
public string Question { get; set; } = "";
public double Volume { get; set; }
public double Liquidity { get; set; }
public DateTime? EndDate { get; set; }
public bool IsResolved { get; set; }
}
@@ -0,0 +1,7 @@
namespace Predictalytics.Application.DTOs;
public class SearchResultsDto
{
public IReadOnlyList<TraderDto> Traders { get; set; } = new List<TraderDto>();
public IReadOnlyList<MarketDto> Markets { get; set; } = new List<MarketDto>();
}
@@ -0,0 +1,18 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.DTOs;
public record TradeDto(
long Id,
int TraderId,
string TraderName,
string Platform,
int? DbMarketId,
string MarketId,
string Outcome,
string Side,
decimal Price,
decimal Size,
decimal Amount,
DateTime ExecutedAt
);
@@ -0,0 +1,21 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.DTOs;
public record TraderDeepDiveDto(
int TraderId,
string DisplayName,
PlatformType Platform,
StrategyType ClassifiedStrategy,
bool IsSuspectedBot,
decimal AvgHoldDurationHours,
decimal AvgPositionSizeUsd,
int MarketsTraded,
decimal HedgingFrequency,
decimal TimingAccuracy,
decimal EntryQuality,
decimal ExitQuality,
string[] BotIndicators,
string Summary,
IReadOnlyList<TradeDto> TradeHistory
);
@@ -0,0 +1,44 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.DTOs;
public record TraderDto(
int Id,
string Platform,
string PlatformUserId,
string DisplayName,
string Tier,
string Strategy,
decimal CombinedScore,
decimal WinRate,
decimal TotalPnl,
int TotalTrades,
bool IsOnWatchlist,
bool IsSuspectedBot,
DateTime? LastPolledAt
);
public record TraderDetailDto(
int Id,
string Platform,
string PlatformUserId,
string DisplayName,
string? Notes,
string Tier,
string Strategy,
bool IsSuspectedBot,
int? ManualPriorityOverride,
decimal WinRate,
decimal TotalPnl,
int TotalTrades,
decimal ActivityScore,
decimal QualityScore,
decimal VolumeScore,
decimal TimingScore,
decimal CombinedScore,
int Rank,
bool IsOnWatchlist,
DateTime CreatedAt,
DateTime? LastPolledAt,
IReadOnlyList<TradeDto> RecentTrades
);
@@ -0,0 +1,19 @@
using Predictalytics.Application.DTOs;
using Predictalytics.Domain.Entities;
namespace Predictalytics.Application.Interfaces;
public interface IAlertService
{
/// <summary>Evaluate alert rules against recent activity and create alerts as needed.</summary>
Task EvaluateAlertsAsync(CancellationToken ct = default);
/// <summary>Create a custom alert.</summary>
Task CreateAlertAsync(Alert alert, CancellationToken ct = default);
/// <summary>Get recent alerts.</summary>
Task<IReadOnlyList<AlertDto>> GetRecentAlertsAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
/// <summary>Mark an alert as read.</summary>
Task MarkAsReadAsync(int alertId, CancellationToken ct = default);
}
@@ -0,0 +1,34 @@
using Predictalytics.Application.DTOs;
using Predictalytics.Domain.ValueObjects;
namespace Predictalytics.Application.Interfaces;
public interface IAnalyticsService
{
/// <summary>Get the main dashboard summary.</summary>
Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default);
/// <summary>Perform deep-dive analysis on a specific trader.</summary>
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
/// <summary>Get trader list with scores.</summary>
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default);
/// <summary>Get a trader's details.</summary>
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
/// <summary>Get list of markets.</summary>
Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default);
/// <summary>Get market details.</summary>
Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default);
/// <summary>Search for traders and markets.</summary>
Task<SearchResultsDto> SearchAsync(string query, CancellationToken ct = default);
/// <summary>Manually trigger a trade history sync for a specific trader.</summary>
Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default);
/// <summary>Manually add a trader by platform and wallet address.</summary>
Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default);
}
@@ -0,0 +1,13 @@
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
namespace Predictalytics.Application.Interfaces;
public interface IDiscoveryService
{
/// <summary>Run discovery to find new/notable traders on a platform.</summary>
Task<IReadOnlyList<DiscoveredTrader>> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default);
/// <summary>Import a discovered trader into the tracking system.</summary>
Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default);
}
@@ -0,0 +1,19 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.Interfaces;
public interface IPlatformStatisticsService
{
void TrackMarketSync(PlatformType platform, int count = 1);
void TrackTraderDiscovery(PlatformType platform, int count = 1);
void TrackTradeActivity(PlatformType platform, int count = 1);
Dictionary<PlatformType, PlatformStats> GetAndResetStats();
}
public class PlatformStats
{
public int MarketsSynced { get; set; }
public int TradersDiscovered { get; set; }
public int TradesProcessed { get; set; }
}
@@ -0,0 +1,18 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.Interfaces;
/// <summary>
/// Token-bucket rate limiter for platform API calls.
/// </summary>
public interface IRateLimiter
{
/// <summary>Wait until a request can be made to the given platform.</summary>
Task WaitAsync(PlatformType platform, CancellationToken ct = default);
/// <summary>Check if a request can be made immediately.</summary>
bool CanMakeRequest(PlatformType platform);
/// <summary>Report that a 429 Too Many Requests was received.</summary>
void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null);
}
@@ -0,0 +1,16 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.ValueObjects;
namespace Predictalytics.Application.Interfaces;
public interface IScoringService
{
/// <summary>Calculate and update priority score for a single trader.</summary>
Task<PriorityScore> CalculateScoreAsync(int traderId, CancellationToken ct = default);
/// <summary>Recalculate scores for all tracked traders.</summary>
Task RecalculateAllScoresAsync(CancellationToken ct = default);
/// <summary>Set a manual priority override for a trader.</summary>
Task SetManualOverrideAsync(int traderId, int? score, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,96 @@
using Predictalytics.Application.DTOs;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
/// <summary>
/// Alert evaluation and management service.
/// </summary>
public class AlertService : IAlertService
{
private readonly IAlertRepository _alertRepo;
private readonly ITradeRepository _tradeRepo;
private readonly ITraderRepository _traderRepo;
private readonly ILogger<AlertService> _logger;
// Alert thresholds (configurable in future)
private const decimal LargePositionThresholdUsd = 5000m;
public AlertService(
IAlertRepository alertRepo,
ITradeRepository tradeRepo,
ITraderRepository traderRepo,
ILogger<AlertService> logger)
{
_alertRepo = alertRepo;
_tradeRepo = tradeRepo;
_traderRepo = traderRepo;
_logger = logger;
}
public async Task EvaluateAlertsAsync(CancellationToken ct = default)
{
_logger.LogDebug("Evaluating alert rules...");
// Check for large recent trades
var recentTrades = await _tradeRepo.GetRecentAsync(100, ct: ct);
var thirtyMinAgo = DateTime.UtcNow.AddMinutes(-30);
foreach (var trade in recentTrades.Where(t => t.ExecutedAt > thirtyMinAgo))
{
if (trade.Amount >= LargePositionThresholdUsd)
{
var trader = await _traderRepo.GetByIdAsync(trade.TraderId, ct);
var marketRef = trade.DbMarketId.HasValue
? $"Market #{trade.DbMarketId}"
: (!string.IsNullOrEmpty(trade.MarketId) ? $"Market {trade.MarketId[..Math.Min(12, trade.MarketId.Length)]}..." : "Unknown Market");
await CreateAlertAsync(new Alert
{
Type = AlertType.LargePosition,
Platform = trade.Platform,
TraderId = trade.TraderId,
Title = $"Large {trade.Side} detected",
Message = $"{trader?.DisplayName ?? "Unknown"} {trade.Side} ${trade.Amount:N0} on {marketRef} ({trade.Outcome} @ {trade.Price:P0})",
Severity = trade.Amount >= 25000 ? 4 : trade.Amount >= 10000 ? 3 : 2
}, ct);
}
}
}
public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default)
{
await _alertRepo.AddAsync(alert, ct);
_logger.LogInformation("🔔 Alert [{Type}]: {Title}", alert.Type, alert.Title);
}
public async Task<IReadOnlyList<AlertDto>> GetRecentAlertsAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var alerts = await _alertRepo.GetRecentAsync(count, unreadOnly, ct);
var result = new List<AlertDto>();
foreach (var a in alerts)
{
string? traderName = null;
if (a.TraderId.HasValue)
{
var trader = await _traderRepo.GetByIdAsync(a.TraderId.Value, ct);
traderName = trader?.DisplayName;
}
result.Add(new AlertDto(
a.Id, a.Type.ToString(), a.Platform.ToString(),
a.Title, a.Message, a.Severity, a.IsRead, a.CreatedAt, traderName));
}
return result;
}
public async Task MarkAsReadAsync(int alertId, CancellationToken ct = default)
{
await _alertRepo.MarkAsReadAsync(alertId, ct);
}
}
@@ -0,0 +1,256 @@
using Predictalytics.Application.DTOs;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
public class AnalyticsService : IAnalyticsService
{
private readonly ITraderRepository _traderRepo;
private readonly ITradeRepository _tradeRepo;
private readonly IAlertRepository _alertRepo;
private readonly IWatchlistRepository _watchlistRepo;
private readonly IMarketRepository _marketRepo;
private readonly IDiscoveryService _discovery;
private readonly ILogger<AnalyticsService> _logger;
public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo,
IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo,
IDiscoveryService discovery, ILogger<AnalyticsService> logger)
{
_traderRepo = traderRepo; _tradeRepo = tradeRepo;
_alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo;
_discovery = discovery; _logger = logger;
}
public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default)
{
var totalTraders = await _traderRepo.GetCountAsync(ct: ct);
var totalTrades = await _tradeRepo.GetCountAsync(ct: ct);
var volume24h = await _tradeRepo.GetTotalVolumeAsync(DateTime.UtcNow.AddHours(-24), ct);
var unreadAlerts = await _alertRepo.GetUnreadCountAsync(ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
// Requirements:
// "Top Traders": successful 5 traders by PnL in the last 7 days.
var topTraders = await _traderRepo.GetTopByPnLAsync(5, DateTime.UtcNow.AddDays(-7), ct);
// "Recent Trades": 5 largest trades in the last 24h.
var largestTrades = await _tradeRepo.GetLargestAsync(5, DateTime.UtcNow.AddHours(-24), ct);
var recentTradesForActivity = await _tradeRepo.GetRecentAsync(500, ct: ct);
var activeTraders24h = recentTradesForActivity.Where(t => t.ExecutedAt > DateTime.UtcNow.AddHours(-24))
.Select(t => t.TraderId).Distinct().Count();
var watchlistIds = watchlist.Select(w => w.TraderId).ToHashSet();
var topTraderDtos = topTraders.Select(t => MapTraderDto(t, watchlistIds)).ToList();
var largestTradeDtos = largestTrades.Select(MapTradeDto).ToList();
var alerts = await _alertRepo.GetRecentAsync(10, ct: ct);
var alertDtos = alerts.Select(a => new AlertDto(a.Id, a.Type.ToString(), a.Platform.ToString(),
a.Title, a.Message, a.Severity, a.IsRead, a.CreatedAt, null)).ToList();
var allTraders = await _traderRepo.GetAllAsync(take: 1000, ct: ct); // Reduced from 10000 for perf
var traderCounts = allTraders.GroupBy(t => t.Platform.ToString()).ToDictionary(g => g.Key, g => g.Count());
var volumeCounts = recentTradesForActivity.Where(t => t.ExecutedAt > DateTime.UtcNow.AddHours(-24))
.GroupBy(t => t.Platform.ToString()).ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
return new DashboardDto(totalTraders, activeTraders24h, totalTrades, volume24h,
unreadAlerts, watchlist.Count, topTraderDtos, largestTradeDtos, alertDtos,
new PlatformBreakdownDto(traderCounts, volumeCounts));
}
public async Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return null;
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 500, ct);
var analysis = PerformDeepDive(trader, trades);
var tradeDtos = trades.Take(100).Select(MapTradeDto).ToList();
return new TraderDeepDiveDto(traderId, trader.DisplayName, trader.Platform,
analysis.ClassifiedStrategy, analysis.IsSuspectedBot, analysis.AvgHoldDurationHours,
analysis.AvgPositionSizeUsd, analysis.MarketsTraded, analysis.HedgingFrequency,
analysis.TimingAccuracy, analysis.EntryQuality, analysis.ExitQuality,
analysis.BotIndicators, analysis.Summary, tradeDtos);
}
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default)
{
PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
pType = pt;
var traders = await _traderRepo.GetAllAsync(platform: pType, skip: skip, take: take, ct: ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
return traders.Select(t => MapTraderDto(t, wIds)).ToList();
}
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default)
{
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
// We will fetch all and filter in memory for now, or you can update repository.
// Let's use GetActiveAsync and map it.
var markets = await _marketRepo.GetActiveAsync(1000, ct);
PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
pType = pt;
var query = markets.AsEnumerable();
if (pType.HasValue)
query = query.Where(m => m.Platform == pType.Value);
var result = query.Skip(skip).Take(take).Select(m => new MarketDto
{
Id = m.Id,
Platform = m.Platform.ToString(),
Question = m.Question,
Volume = (double)m.Volume,
Liquidity = (double)m.Liquidity,
EndDate = m.EndDate,
IsResolved = m.IsResolved
}).ToList();
return result;
}
public async Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return null;
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 50, ct);
var wl = await _watchlistRepo.GetByTraderIdAsync(traderId, ct);
var s = trader.CurrentScore;
return new TraderDetailDto(trader.Id, trader.Platform.ToString(), trader.PlatformUserId, trader.DisplayName,
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
trader.WinRate, trader.TotalPnl, trader.TotalTrades,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trades.Select(MapTradeDto).ToList());
}
public async Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default)
{
var market = await _marketRepo.GetByIdAsync(marketId, ct);
if (market == null) return null;
// Prefer fast INT FK lookup; fall back to string scan for legacy unlinked trades
var recentTrades = await _tradeRepo.GetByDbMarketIdAsync(marketId, 0, 50, ct);
if (recentTrades.Count == 0)
{
// Fallback: trades ingested before DbMarketId backfill
recentTrades = await _tradeRepo.GetByMarketIdAsync(market.PlatformMarketId, 0, 50, ct);
}
return new MarketDetailDto
{
Id = market.Id,
Platform = market.Platform.ToString(),
PlatformMarketId = market.PlatformMarketId,
Question = market.Question,
Description = market.Description,
Category = market.Category,
Volume = (double)market.Volume,
Liquidity = (double)market.Liquidity,
EndDate = market.EndDate,
IsResolved = market.IsResolved,
ResolutionOutcome = market.ResolutionOutcome,
ImageUrl = market.ImageUrl,
Outcomes = market.Outcomes.Select(o => new MarketOutcomeDto { Name = o.Label, Price = (double)o.CurrentPrice }).ToList(),
RecentTrades = recentTrades.Select(MapTradeDto).ToList()
};
}
public async Task<SearchResultsDto> SearchAsync(string query, CancellationToken ct = default)
{
var traders = await _traderRepo.SearchAsync(query, 20, ct);
var markets = await _marketRepo.SearchAsync(query, 20, ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
return new SearchResultsDto
{
Traders = traders.Select(t => MapTraderDto(t, wIds)).ToList(),
Markets = markets.Select(m => new MarketDto
{
Id = m.Id,
Platform = m.Platform.ToString(),
Question = m.Question,
Volume = (double)m.Volume,
Liquidity = (double)m.Liquidity,
EndDate = m.EndDate,
IsResolved = m.IsResolved
}).ToList()
};
}
private TraderAnalysis PerformDeepDive(Trader trader, IReadOnlyList<Trade> trades)
{
if (trades.Count == 0)
return new TraderAnalysis(trader.Id, StrategyType.Unknown, false, 0, 0, 0, 0, 50, 50, 50,
Array.Empty<string>(), "Insufficient data.");
// Use DbMarketId when available, fall back to MarketId string for older trades
var marketKeys = trades
.Select(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
.Where(k => !string.IsNullOrEmpty(k))
.ToList();
var marketsTraded = marketKeys.Distinct().Count();
var avgSize = trades.Average(t => t.Amount);
var botIndicators = new List<string>();
var times = trades.Select(t => t.ExecutedAt).OrderBy(t => t).ToList();
if (times.Count > 10)
{
var intervals = times.Zip(times.Skip(1), (a, b) => (b - a).TotalSeconds).ToList();
if (intervals.Average() < 10) botIndicators.Add("Sub-10s trade frequency");
}
// Group by unified market key to find hedged markets (both Yes and No held)
var hedgeGroups = trades
.GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
.Where(g => !string.IsNullOrEmpty(g.Key) && g.Select(t => t.Outcome).Distinct().Count() > 1);
var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0;
var strategy = avgSize > 10000 ? StrategyType.Whale : hedgingRate > 30 ? StrategyType.Hedger :
botIndicators.Count > 0 ? StrategyType.Bot : StrategyType.Unknown;
return new TraderAnalysis(trader.Id, strategy, botIndicators.Count > 1, 0, avgSize, marketsTraded,
hedgingRate, 50, 50, 50, botIndicators.ToArray(),
$"{trader.DisplayName}: {strategy}, {marketsTraded} markets, avg ${avgSize:N0}");
}
public async Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader != null)
{
trader.LastTradesUpdatedAt = null; // Force worker to pick it up
await _traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Manually triggered trade sync for trader {TraderId} ({Name})", traderId, trader.DisplayName);
}
}
public async Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default)
{
if (!Enum.TryParse<PlatformType>(platform, true, out var pType))
throw new ArgumentException($"Invalid platform: {platform}");
_logger.LogInformation("Manually adding trader {Wallet} for platform {Platform}", walletAddress, platform);
return await _discovery.ImportTraderAsync(pType, walletAddress, walletAddress[..Math.Min(10, walletAddress.Length)] + "...", ct);
}
private static TraderDto MapTraderDto(Trader t, HashSet<int> wIds) => new(
t.Id, t.Platform.ToString(), t.PlatformUserId, t.DisplayName, t.Tier.ToString(), t.Strategy.ToString(),
t.CurrentScore?.CombinedScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades,
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
private static TradeDto MapTradeDto(Trade t) => new(
t.Id, t.TraderId, t.Trader?.DisplayName ?? "—", t.Platform.ToString(),
t.DbMarketId, t.MarketId, t.Outcome, t.Side.ToString(), t.Price, t.Size, t.Amount, t.ExecutedAt);
}
@@ -0,0 +1,82 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
/// <summary>
/// Discovers new notable traders on prediction market platforms.
/// </summary>
public class DiscoveryService : IDiscoveryService
{
private readonly IEnumerable<IPlatformProvider> _providers;
private readonly ITraderRepository _traderRepo;
private readonly IRateLimiter _rateLimiter;
private readonly ILogger<DiscoveryService> _logger;
public DiscoveryService(
IEnumerable<IPlatformProvider> providers,
ITraderRepository traderRepo,
IRateLimiter rateLimiter,
ILogger<DiscoveryService> logger)
{
_providers = providers;
_traderRepo = traderRepo;
_rateLimiter = rateLimiter;
_logger = logger;
}
public async Task<IReadOnlyList<DiscoveredTrader>> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default)
{
var provider = _providers.FirstOrDefault(p => p.Platform == platform);
if (provider == null || !provider.IsImplemented)
{
_logger.LogWarning("No implemented provider for platform {Platform}", platform);
return Array.Empty<DiscoveredTrader>();
}
await _rateLimiter.WaitAsync(platform, ct);
_logger.LogInformation("[{Platform}] Running trader discovery scan...", platform);
var discovered = await provider.DiscoverTradersAsync(50, ct);
int newCount = 0;
foreach (var d in discovered)
{
var existing = await _traderRepo.GetByPlatformIdAsync(platform, d.PlatformUserId, ct);
if (existing == null)
{
await ImportTraderAsync(platform, d.PlatformUserId, d.DisplayName, ct);
newCount++;
}
}
_logger.LogInformation("[{Platform}] Discovery complete: {Total} found, {New} new traders imported",
platform, discovered.Count, newCount);
return discovered;
}
public async Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default)
{
var existing = await _traderRepo.GetByPlatformIdAsync(platform, platformUserId, ct);
if (existing != null) return existing.Id;
var trader = new Trader
{
Platform = platform,
PlatformUserId = platformUserId,
DisplayName = string.IsNullOrEmpty(displayName) ? platformUserId[..8] + "..." : displayName,
IsAutoDiscovered = true,
CreatedAt = DateTime.UtcNow
};
await _traderRepo.AddAsync(trader, ct);
_logger.LogInformation("[{Platform}] Imported new trader: {Name} ({Id})",
platform, trader.DisplayName, trader.PlatformUserId);
return trader.Id;
}
}
@@ -0,0 +1,44 @@
using System.Collections.Concurrent;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.Services;
public class PlatformStatisticsService : IPlatformStatisticsService
{
private readonly ConcurrentDictionary<PlatformType, PlatformStats> _stats = new();
public void TrackMarketSync(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.MarketsSynced += count;
}
public void TrackTraderDiscovery(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.TradersDiscovered += count;
}
public void TrackTradeActivity(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.TradesProcessed += count;
}
public Dictionary<PlatformType, PlatformStats> GetAndResetStats()
{
var result = new Dictionary<PlatformType, PlatformStats>();
var platforms = Enum.GetValues<PlatformType>();
foreach (var p in platforms)
{
if (_stats.TryRemove(p, out var stats))
{
result[p] = stats;
}
}
return result;
}
}
@@ -0,0 +1,72 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using System.Collections.Concurrent;
namespace Predictalytics.Application.Services;
/// <summary>
/// Token-bucket rate limiter with per-platform configuration.
/// </summary>
public class RateLimiterService : IRateLimiter
{
private readonly ConcurrentDictionary<PlatformType, SemaphoreSlim> _semaphores = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _lastRequest = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _blockedUntil = new();
// Minimum delay between requests per platform (milliseconds)
private static readonly Dictionary<PlatformType, int> PlatformDelays = new()
{
{ PlatformType.Polymarket, 200 },
{ PlatformType.Limitless, 500 },
{ PlatformType.Azuro, 1000 },
{ PlatformType.Myriad, 1000 },
{ PlatformType.PredictFun, 1000 },
{ PlatformType.Kalshi, 500 },
{ PlatformType.Stake, 1000 }
};
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default)
{
var sem = _semaphores.GetOrAdd(platform, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct);
try
{
// 1. Check if we are currently blocked due to a 429
if (_blockedUntil.TryGetValue(platform, out var blockedUntil))
{
var waitTime = blockedUntil - DateTime.UtcNow;
if (waitTime > TimeSpan.Zero)
{
await Task.Delay(waitTime, ct);
}
}
// 2. Normal token bucket delay
if (_lastRequest.TryGetValue(platform, out var last))
{
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
if (elapsed < delayMs)
await Task.Delay((int)(delayMs - elapsed), ct);
}
_lastRequest[platform] = DateTime.UtcNow;
}
finally { sem.Release(); }
}
public bool CanMakeRequest(PlatformType platform)
{
if (_blockedUntil.TryGetValue(platform, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
return false;
if (!_lastRequest.TryGetValue(platform, out var last)) return true;
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs;
}
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null)
{
var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
_blockedUntil[platform] = DateTime.UtcNow.Add(penalty);
}
}
@@ -0,0 +1,184 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
/// <summary>
/// Calculates priority and quality scores for traders based on their activity,
/// performance, and trading patterns.
/// </summary>
public class ScoringService : IScoringService
{
private readonly ITraderRepository _traderRepo;
private readonly ITradeRepository _tradeRepo;
private readonly ILogger<ScoringService> _logger;
// Scoring weights (configurable in future)
private const decimal ActivityWeight = 0.25m;
private const decimal QualityWeight = 0.35m;
private const decimal VolumeWeight = 0.20m;
private const decimal TimingWeight = 0.20m;
public ScoringService(ITraderRepository traderRepo, ITradeRepository tradeRepo, ILogger<ScoringService> logger)
{
_traderRepo = traderRepo;
_tradeRepo = tradeRepo;
_logger = logger;
}
public async Task<PriorityScore> CalculateScoreAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null)
{
_logger.LogWarning("Cannot score trader {TraderId}: not found", traderId);
return new PriorityScore(0, 0, 0, 0, 0);
}
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 200, ct);
// Activity Score: based on trade frequency and recency
var activityScore = CalculateActivityScore(trades);
// Quality Score: based on win rate and PnL
var qualityScore = CalculateQualityScore(trader);
// Volume Score: based on average trade size
var volumeScore = CalculateVolumeScore(trades);
// Timing Score: based on entry/exit timing quality
var timingScore = CalculateTimingScore(trades);
// Combined weighted score
var combined = Math.Round(
activityScore * ActivityWeight +
qualityScore * QualityWeight +
volumeScore * VolumeWeight +
timingScore * TimingWeight, 2);
var score = new PriorityScore(activityScore, qualityScore, volumeScore, timingScore, combined, trader.ManualPriorityOverride);
// Persist score
var traderScore = trader.CurrentScore ?? new TraderScore { TraderId = traderId };
traderScore.ActivityScore = activityScore;
traderScore.QualityScore = qualityScore;
traderScore.VolumeScore = volumeScore;
traderScore.TimingScore = timingScore;
traderScore.CombinedScore = combined;
traderScore.CalculatedAt = DateTime.UtcNow;
trader.CurrentScore = traderScore;
trader.Tier = score.DetermineTier();
await _traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Scored trader {TraderName} ({TraderId}): Combined={Score}, Tier={Tier}",
trader.DisplayName, traderId, combined, trader.Tier);
return score;
}
public async Task RecalculateAllScoresAsync(CancellationToken ct = default)
{
var traders = await _traderRepo.GetAllAsync(take: 1000, ct: ct);
_logger.LogInformation("Recalculating scores for {Count} traders", traders.Count);
int rank = 1;
var scored = new List<(int TraderId, decimal Score)>();
foreach (var trader in traders)
{
if (ct.IsCancellationRequested) break;
var score = await CalculateScoreAsync(trader.Id, ct);
scored.Add((trader.Id, score.EffectiveScore));
}
// Update ranks
foreach (var (id, _) in scored.OrderByDescending(s => s.Score))
{
var trader = await _traderRepo.GetByIdAsync(id, ct);
if (trader?.CurrentScore != null)
{
trader.CurrentScore.Rank = rank++;
await _traderRepo.UpdateAsync(trader, ct);
}
}
_logger.LogInformation("Score recalculation complete. Ranked {Count} traders.", scored.Count);
}
public async Task SetManualOverrideAsync(int traderId, int? score, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return;
trader.ManualPriorityOverride = score;
await _traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Set manual priority override for {TraderName}: {Score}", trader.DisplayName, score?.ToString() ?? "cleared");
}
private static decimal CalculateActivityScore(IReadOnlyList<Trade> trades)
{
if (trades.Count == 0) return 0;
var now = DateTime.UtcNow;
var recentTrades = trades.Where(t => t.ExecutedAt > now.AddDays(-7)).ToList();
var frequencyScore = Math.Min(recentTrades.Count / 10.0m, 1.0m) * 50;
// Recency bonus
var lastTrade = trades.MaxBy(t => t.ExecutedAt);
var hoursSinceLast = (decimal)(now - (lastTrade?.ExecutedAt ?? now.AddDays(-30))).TotalHours;
var recencyScore = Math.Max(0, 50 - hoursSinceLast / 2);
return Math.Min(Math.Round(frequencyScore + recencyScore, 2), 100);
}
private static decimal CalculateQualityScore(Trader trader)
{
// Win rate contribution (0-60 points)
var winRateScore = trader.WinRate * 0.6m;
// PnL contribution (0-40 points) - logarithmic scale
var pnlScore = trader.TotalPnl > 0
? Math.Min((decimal)Math.Log10((double)trader.TotalPnl + 1) * 10, 40)
: 0;
return Math.Min(Math.Round(winRateScore + pnlScore, 2), 100);
}
private static decimal CalculateVolumeScore(IReadOnlyList<Trade> trades)
{
if (trades.Count == 0) return 0;
var avgAmount = trades.Average(t => t.Amount);
// Score based on average trade size (logarithmic)
var score = (decimal)Math.Log10((double)avgAmount + 1) * 25;
return Math.Min(Math.Round(score, 2), 100);
}
private static decimal CalculateTimingScore(IReadOnlyList<Trade> trades)
{
if (trades.Count < 2) return 50; // neutral if insufficient data
// Simple heuristic: variety in execution times suggests deliberate timing
var hours = trades.Select(t => t.ExecutedAt.Hour).Distinct().Count();
var timeSpread = Math.Min(hours / 12.0m, 1.0m) * 50;
// Consistency: regular intervals suggest discipline
var intervals = trades.OrderBy(t => t.ExecutedAt)
.Zip(trades.OrderBy(t => t.ExecutedAt).Skip(1), (a, b) => (b.ExecutedAt - a.ExecutedAt).TotalHours)
.ToList();
decimal consistencyScore = 50;
if (intervals.Count > 0)
{
var avgInterval = intervals.Average();
var stdDev = Math.Sqrt(intervals.Average(i => Math.Pow(i - avgInterval, 2)));
consistencyScore = (decimal)Math.Max(0, 50 - stdDev);
}
return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100);
}
}
@@ -0,0 +1,32 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
public class WatchlistService
{
private readonly IWatchlistRepository _repo;
private readonly ITraderRepository _traderRepo;
private readonly ILogger<WatchlistService> _logger;
public WatchlistService(IWatchlistRepository repo, ITraderRepository traderRepo, ILogger<WatchlistService> logger)
{ _repo = repo; _traderRepo = traderRepo; _logger = logger; }
public Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default) => _repo.GetAllAsync(ct);
public async Task AddAsync(int traderId, string label, string? notes = null, CancellationToken ct = default)
{
var existing = await _repo.GetByTraderIdAsync(traderId, ct);
if (existing != null) return;
await _repo.AddAsync(new WatchlistEntry { TraderId = traderId, Label = label, Notes = notes }, ct);
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
_logger.LogInformation("Added {Trader} to watchlist", trader?.DisplayName ?? traderId.ToString());
}
public async Task RemoveAsync(int id, CancellationToken ct = default)
{
await _repo.RemoveAsync(id, ct);
_logger.LogInformation("Removed watchlist entry {Id}", id);
}
}
@@ -0,0 +1,38 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// An alert triggered by the system based on configurable rules.
/// </summary>
public class Alert
{
public int Id { get; set; }
/// <summary>Type of alert.</summary>
public AlertType Type { get; set; }
/// <summary>Platform where the triggering event occurred.</summary>
public PlatformType Platform { get; set; }
/// <summary>Optional reference to the trader who triggered this alert.</summary>
public int? TraderId { get; set; }
/// <summary>Alert title / headline.</summary>
public string Title { get; set; } = string.Empty;
/// <summary>Detailed message describing the alert.</summary>
public string Message { get; set; } = string.Empty;
/// <summary>Severity: 1=Low, 2=Medium, 3=High, 4=Critical.</summary>
public int Severity { get; set; } = 1;
/// <summary>Whether the alert has been read/acknowledged.</summary>
public bool IsRead { get; set; }
/// <summary>When the alert was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Trader? Trader { get; set; }
}
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Predictalytics.Domain.Entities;
public class TraderAnalytics
{
[Key, ForeignKey("Trader")]
public int TraderId { get; set; }
public DateTime LastCalculatedAt { get; set; } = DateTime.UtcNow;
public decimal OverallPnL { get; set; }
public decimal OverallWinRate { get; set; }
public decimal PnL30d { get; set; }
public decimal WinRate30d { get; set; }
public decimal PnL7d { get; set; }
public decimal WinRate7d { get; set; }
public decimal PnL24h { get; set; }
public decimal WinRate24h { get; set; }
// Navigation
public virtual Trader Trader { get; set; } = null!;
}
public class MarketAnalytics
{
[Key, ForeignKey("Market")]
public int MarketId { get; set; }
public DateTime LastCalculatedAt { get; set; } = DateTime.UtcNow;
public decimal BotActivityScore { get; set; }
public int UniqueTradersCount { get; set; }
public decimal AverageTradeSize { get; set; }
// Navigation
public virtual Market Market { get; set; } = null!;
}
@@ -0,0 +1,69 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a prediction market (event/question).
/// </summary>
public class Market
{
public int Id { get; set; }
/// <summary>Platform this market belongs to.</summary>
public PlatformType Platform { get; set; }
/// <summary>Platform-specific market identifier (conditionId on Polymarket).</summary>
public string PlatformMarketId { get; set; } = string.Empty;
/// <summary>URL-friendly slug for the market.</summary>
public string MarketSlug { get; set; } = string.Empty;
/// <summary>URL-friendly slug for the parent event.</summary>
public string EventSlug { get; set; } = string.Empty;
/// <summary>Detailed market description / resolution criteria.</summary>
public string? Description { get; set; }
/// <summary>Market image URL.</summary>
public string? ImageUrl { get; set; }
/// <summary>The question being predicted.</summary>
public string Question { get; set; } = string.Empty;
/// <summary>Category / tag (e.g. "Politics", "Crypto", "Sports").</summary>
public string Category { get; set; } = string.Empty;
/// <summary>Current total volume traded.</summary>
public decimal Volume { get; set; }
/// <summary>Current liquidity.</summary>
public decimal Liquidity { get; set; }
/// <summary>The time when trading opened for this market.</summary>
public DateTime? StartDate { get; set; }
/// <summary>When the market closes / resolves.</summary>
public DateTime? EndDate { get; set; }
/// <summary>Whether the market has been resolved.</summary>
public bool IsResolved { get; set; }
/// <summary>Resolution outcome (if resolved).</summary>
public string? ResolutionOutcome { get; set; }
/// <summary>When this market was created on the platform.</summary>
public DateTime CreatedAt { get; set; }
/// <summary>When this record was first saved to our database.</summary>
public DateTime DbCreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Last time market data was refreshed.</summary>
public DateTime? LastUpdatedAt { get; set; }
/// <summary>Last time market trades were polled for trader discovery.</summary>
public DateTime? LastTradesUpdatedAt { get; set; }
// Navigation
public ICollection<MarketOutcome> Outcomes { get; set; } = new List<MarketOutcome>();
public virtual MarketAnalytics? Analytics { get; set; }
}
@@ -0,0 +1,32 @@
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a single tradeable outcome within a market.
/// For example, "Yes" and "No" on a binary market, or named outcomes on a multi-outcome market.
/// The TokenId (clobTokenId on Polymarket) is the key link between trades and outcomes.
/// </summary>
public class MarketOutcome
{
public int Id { get; set; }
/// <summary>Foreign key to the parent market.</summary>
public int MarketId { get; set; }
/// <summary>Human-readable outcome label (e.g. "Yes", "No", "Trump", "Biden").</summary>
public string Label { get; set; } = string.Empty;
/// <summary>Positional index of this outcome within the market (0-based).</summary>
public int OutcomeIndex { get; set; }
/// <summary>
/// Platform-specific token identifier for this outcome.
/// On Polymarket this is the clobTokenId — the key used in trade asset_id fields.
/// </summary>
public string TokenId { get; set; } = string.Empty;
/// <summary>Current price of this outcome (0.00 to 1.00).</summary>
public decimal CurrentPrice { get; set; }
// Navigation
public Market Market { get; set; } = null!;
}
@@ -0,0 +1,15 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Entities;
public class PlatformConfig
{
public PlatformType Id { get; set; }
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public bool IsActive { get; set; } = true;
public string? BaseUrl { get; set; }
public string? SettingsJson { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,82 @@
using Predictalytics.Domain.Enums;
using System.ComponentModel.DataAnnotations.Schema;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a single trade executed by a trader on a prediction market.
/// </summary>
public class Trade
{
public long Id { get; set; }
/// <summary>Foreign key to the trader who made this trade.</summary>
public int TraderId { get; set; }
/// <summary>Platform where the trade occurred.</summary>
public PlatformType Platform { get; set; }
/// <summary>
/// Platform-specific trade ID for deduplication.
/// Format: "{txHash}_{assetId}_{side}" (max ~143 chars for new trades).
/// Legacy trades may include wallet address as 2nd segment.
/// </summary>
public string PlatformTradeId { get; set; } = string.Empty;
/// <summary>
/// Platform-specific market identifier string (conditionId on Polymarket, address on Limitless).
/// Kept as VARCHAR(66) for cross-referencing during import and reconciliation.
/// After full linking, prefer DbMarketId.
/// </summary>
public string MarketId { get; set; } = string.Empty;
/// <summary>
/// Foreign key to the internal Markets table. Populated during import when the market is known.
/// Null for trades whose market has not yet been synced.
/// </summary>
public int? DbMarketId { get; set; }
/// <summary>
/// Platform-specific token/asset ID (clobTokenId on Polymarket). Links to MarketOutcome.TokenId.
/// Kept as VARCHAR(66) until MarketOutcomeId is resolved.
/// </summary>
public string AssetId { get; set; } = string.Empty;
/// <summary>Foreign key to the resolved MarketOutcome (nullable until resolved via market sync).</summary>
public int? MarketOutcomeId { get; set; }
/// <summary>The outcome the trader bet on (e.g. "Yes", "No").</summary>
public string Outcome { get; set; } = string.Empty;
/// <summary>Buy or Sell.</summary>
public TradeSide Side { get; set; }
/// <summary>Price per share at execution (0.00 to 1.00 on Polymarket).</summary>
public decimal Price { get; set; }
/// <summary>Number of shares/tokens traded.</summary>
public decimal Size { get; set; }
/// <summary>Total notional value in USD.</summary>
public decimal Amount { get; set; }
/// <summary>When the trade was executed on the platform.</summary>
public DateTime ExecutedAt { get; set; }
/// <summary>Transaction hash (for blockchain-based platforms).</summary>
public string? TransactionHash { get; set; }
// ── Transient (not persisted) ──────────────────────────────────────────
/// <summary>
/// Wallet address of the trader, set by the provider during data ingestion.
/// NOT stored in the database — used transiently for trader discovery in MarketHistoryWorker.
/// </summary>
[NotMapped]
public string? TransientWallet { get; set; }
// ── Navigation ────────────────────────────────────────────────────────
public Trader Trader { get; set; } = null!;
public MarketOutcome? MarketOutcome { get; set; }
public Market? DbMarket { get; set; }
}
@@ -0,0 +1,69 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a trader on a prediction market platform.
/// Identity is composite: PlatformType + PlatformUserId (e.g. wallet address on Polymarket).
/// </summary>
public class Trader
{
public int Id { get; set; }
/// <summary>The platform this trader belongs to.</summary>
public PlatformType Platform { get; set; }
/// <summary>Platform-specific user identifier (e.g. Ethereum wallet address for Polymarket).</summary>
public string PlatformUserId { get; set; } = string.Empty;
/// <summary>Display name / alias (can be auto-discovered or manually set).</summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>Optional notes about the trader.</summary>
public string? Notes { get; set; }
/// <summary>Whether this trader was auto-discovered or manually added.</summary>
public bool IsAutoDiscovered { get; set; }
/// <summary>Current tier classification.</summary>
public TraderTier Tier { get; set; } = TraderTier.Unknown;
/// <summary>Classified strategy type from deep-dive analysis.</summary>
public StrategyType Strategy { get; set; } = StrategyType.Unknown;
/// <summary>Whether the trader shows bot-like behavior.</summary>
public bool IsSuspectedBot { get; set; }
/// <summary>Manual priority override (null = use calculated score).</summary>
public int? ManualPriorityOverride { get; set; }
/// <summary>When this trader was first tracked.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>When data was last polled for this trader.</summary>
public DateTime? LastPolledAt { get; set; }
/// <summary>When the trader's trade history was last fully synced. Used for cooldown.</summary>
public DateTime? LastTradesUpdatedAt { get; set; }
/// <summary>Whether the initial full historical trade import is complete.</summary>
public bool IsInitialImportComplete { get; set; }
/// <summary>When the API first returned an error (e.g. 404 for deleted account).</summary>
public DateTime? LastApiErrorAt { get; set; }
/// <summary>Total estimated PnL across all resolved markets.</summary>
public decimal TotalPnl { get; set; }
/// <summary>Win rate as a percentage (0-100).</summary>
public decimal WinRate { get; set; }
/// <summary>Total number of trades tracked.</summary>
public int TotalTrades { get; set; }
// Navigation properties
public ICollection<Trade> Trades { get; set; } = new List<Trade>();
public TraderScore? CurrentScore { get; set; }
public virtual TraderAnalytics? Analytics { get; set; }
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
}
@@ -0,0 +1,37 @@
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Calculated priority and quality score for a trader.
/// Updated periodically by the scoring engine.
/// </summary>
public class TraderScore
{
public int Id { get; set; }
/// <summary>Foreign key to trader.</summary>
public int TraderId { get; set; }
/// <summary>Activity score: frequency, recency, and consistency of trading (0-100).</summary>
public decimal ActivityScore { get; set; }
/// <summary>Quality score: win rate, ROI, risk management quality (0-100).</summary>
public decimal QualityScore { get; set; }
/// <summary>Combined weighted score (0-100).</summary>
public decimal CombinedScore { get; set; }
/// <summary>Volume score: size of positions relative to market (0-100).</summary>
public decimal VolumeScore { get; set; }
/// <summary>Timing score: how well-timed entries and exits are (0-100).</summary>
public decimal TimingScore { get; set; }
/// <summary>Overall rank among all tracked traders.</summary>
public int Rank { get; set; }
/// <summary>When this score was last calculated.</summary>
public DateTime CalculatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Trader Trader { get; set; } = null!;
}
@@ -0,0 +1,27 @@
namespace Predictalytics.Domain.Entities;
/// <summary>
/// A trader that has been added to the user's watchlist for prioritized tracking.
/// </summary>
public class WatchlistEntry
{
public int Id { get; set; }
/// <summary>Foreign key to the watched trader.</summary>
public int TraderId { get; set; }
/// <summary>User-defined label for this watchlist entry.</summary>
public string Label { get; set; } = string.Empty;
/// <summary>Optional notes about why this trader is watched.</summary>
public string? Notes { get; set; }
/// <summary>Whether to receive alerts for this trader's activity.</summary>
public bool AlertsEnabled { get; set; } = true;
/// <summary>When this entry was added to the watchlist.</summary>
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Trader Trader { get; set; } = null!;
}
@@ -0,0 +1,17 @@
namespace Predictalytics.Domain.Enums;
public enum AlertType
{
/// <summary>Large position opened by tracked trader</summary>
LargePosition = 0,
/// <summary>New trader discovered matching criteria</summary>
NewTraderDiscovered = 1,
/// <summary>Trader's score changed significantly</summary>
ScoreChange = 2,
/// <summary>Unusual activity pattern detected</summary>
UnusualActivity = 3,
/// <summary>Trader exited a position completely</summary>
PositionExit = 4,
/// <summary>Custom user-defined alert</summary>
Custom = 5
}
@@ -0,0 +1,16 @@
namespace Predictalytics.Domain.Enums;
/// <summary>
/// Supported prediction market platforms.
/// Add new platforms here as they are integrated.
/// </summary>
public enum PlatformType
{
Polymarket = 0,
Limitless = 1,
Azuro = 2,
Myriad = 3,
PredictFun = 4,
Kalshi = 5,
Stake = 6
}
@@ -0,0 +1,17 @@
namespace Predictalytics.Domain.Enums;
/// <summary>
/// Classified trading strategy types for deep-dive analysis.
/// </summary>
public enum StrategyType
{
Unknown = 0,
Scalper = 1,
SwingTrader = 2,
Whale = 3,
Hedger = 4,
Contrarian = 5,
MomentumTrader = 6,
Arbitrageur = 7,
Bot = 8
}
@@ -0,0 +1,13 @@
namespace Predictalytics.Domain.Enums;
public enum TradeSide
{
Buy = 0,
Sell = 1,
Split = 2,
Merge = 3,
Redeem = 4,
AddLiquidity = 5,
RemoveLiquidity = 6,
Unknown = 99
}
@@ -0,0 +1,14 @@
namespace Predictalytics.Domain.Enums;
/// <summary>
/// Trader quality tiers based on scoring.
/// </summary>
public enum TraderTier
{
Unknown = 0,
Bronze = 1,
Silver = 2,
Gold = 3,
Platinum = 4,
Diamond = 5
}
@@ -0,0 +1,11 @@
using Predictalytics.Domain.Entities;
namespace Predictalytics.Domain.Interfaces;
public interface IAlertRepository
{
Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
Task AddAsync(Alert alert, CancellationToken ct = default);
Task MarkAsReadAsync(int id, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(CancellationToken ct = default);
}
@@ -0,0 +1,19 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Interfaces;
public interface IMarketRepository
{
Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default);
Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default);
Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default);
Task AddOrUpdateAsync(Market market, CancellationToken ct = default);
Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default);
Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default);
Task<int> GetCountAsync(CancellationToken ct = default);
Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default);
Task UpdateAsync(Market market, CancellationToken ct = default);
Task<Market?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default);
}
@@ -0,0 +1,66 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Interfaces;
/// <summary>
/// Platform-agnostic provider interface. Each prediction market platform
/// must implement this to be integrated into the system.
/// </summary>
public interface IPlatformProvider
{
/// <summary>Which platform this provider serves.</summary>
PlatformType Platform { get; }
/// <summary>Human-readable name of the platform.</summary>
string PlatformName { get; }
/// <summary>Whether this provider is fully implemented and operational.</summary>
bool IsImplemented { get; }
/// <summary>Fetch recent trades for a specific trader.</summary>
Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default);
/// <summary>Fetch current positions/holdings for a trader.</summary>
Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default);
/// <summary>Discover notable/active traders on the platform.</summary>
Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default);
/// <summary>Fetch market metadata by platform-specific market ID.</summary>
Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default);
/// <summary>Fetch a batch of markets with their outcomes for bulk sync.</summary>
Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default);
/// <summary>Fetch top holders for a market to discover new traders.</summary>
Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default);
/// <summary>Fetch recent trades that occurred on a specific market.</summary>
Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default);
}
/// <summary>
/// A trader's current position in a market.
/// </summary>
public record TraderPositionInfo(
string PlatformUserId,
string MarketId,
string MarketQuestion,
string Outcome,
decimal Size,
decimal AveragePrice,
decimal CurrentValue,
decimal PnlPercent
);
/// <summary>
/// A trader discovered during auto-discovery scanning.
/// </summary>
public record DiscoveredTrader(
string PlatformUserId,
string DisplayName,
decimal Volume24h,
int TradeCount24h,
decimal WinRate
);
@@ -0,0 +1,22 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Interfaces;
public interface ITradeRepository
{
Task<Trade?> GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default);
Task<IReadOnlyList<Trade>> GetByTraderIdAsync(int traderId, int skip = 0, int take = 50, CancellationToken ct = default);
/// <summary>Fast INT-based market lookup (preferred after DbMarketId backfill).</summary>
Task<IReadOnlyList<Trade>> GetByDbMarketIdAsync(int dbMarketId, int skip = 0, int take = 50, CancellationToken ct = default);
/// <summary>String-based fallback for trades not yet linked to a DB market.</summary>
Task<IReadOnlyList<Trade>> GetByMarketIdAsync(string platformMarketId, int skip = 0, int take = 50, CancellationToken ct = default);
Task<IReadOnlyList<Trade>> GetRecentAsync(int count = 50, PlatformType? platform = null, CancellationToken ct = default);
Task<IReadOnlyList<Trade>> GetLargestAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
Task<int> GetCountAsync(int? traderId = null, CancellationToken ct = default);
Task AddRangeAsync(IEnumerable<Trade> trades, CancellationToken ct = default);
Task<decimal> GetTotalVolumeAsync(DateTime? since = null, CancellationToken ct = default);
Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default);
Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, CancellationToken ct = default);
Task UpdateAsync(Trade trade, CancellationToken ct = default);
}
@@ -0,0 +1,26 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Interfaces;
public interface ITraderRepository
{
Task<Trader?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default);
Task AddAsync(Trader trader, CancellationToken ct = default);
Task UpdateAsync(Trader trader, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
/// <summary>Get traders that need trade history update (LastTradesUpdatedAt is null or older than given hours).</summary>
Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 6, int take = 20, CancellationToken ct = default);
/// <summary>Get traders that haven't been polled in a long time or have a prolonged API error for cleanup.</summary>
Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using Predictalytics.Domain.Entities;
namespace Predictalytics.Domain.Interfaces;
public interface IWatchlistRepository
{
Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default);
Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
Task AddAsync(WatchlistEntry entry, CancellationToken ct = default);
Task RemoveAsync(int id, CancellationToken ct = default);
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Domain</RootNamespace>
</PropertyGroup>
</Project>
@@ -0,0 +1,36 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.ValueObjects;
/// <summary>
/// Encapsulates a trader's calculated priority score with all component scores.
/// </summary>
public record PriorityScore(
decimal ActivityScore,
decimal QualityScore,
decimal VolumeScore,
decimal TimingScore,
decimal CombinedScore,
int? ManualOverride = null
)
{
/// <summary>
/// The effective score, considering any manual override.
/// </summary>
public decimal EffectiveScore => ManualOverride.HasValue
? ManualOverride.Value
: CombinedScore;
/// <summary>
/// Determine tier based on effective score.
/// </summary>
public TraderTier DetermineTier() => EffectiveScore switch
{
>= 90 => TraderTier.Diamond,
>= 75 => TraderTier.Platinum,
>= 60 => TraderTier.Gold,
>= 40 => TraderTier.Silver,
>= 20 => TraderTier.Bronze,
_ => TraderTier.Unknown
};
}
@@ -0,0 +1,21 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.ValueObjects;
/// <summary>
/// Result of a deep-dive analysis for a trader.
/// </summary>
public record TraderAnalysis(
int TraderId,
StrategyType ClassifiedStrategy,
bool IsSuspectedBot,
decimal AvgHoldDurationHours,
decimal AvgPositionSizeUsd,
int MarketsTraded,
decimal HedgingFrequency,
decimal TimingAccuracy,
decimal EntryQuality,
decimal ExitQuality,
string[] BotIndicators,
string Summary
);
@@ -0,0 +1,157 @@
using Predictalytics.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Infrastructure.Data;
public class AppDbContext : DbContext
{
public DbSet<Trader> Traders => Set<Trader>();
public DbSet<Trade> Trades => Set<Trade>();
public DbSet<Market> Markets => Set<Market>();
public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>();
public DbSet<TraderScore> TraderScores => Set<TraderScore>();
public DbSet<WatchlistEntry> WatchlistEntries => Set<WatchlistEntry>();
public DbSet<Alert> Alerts => Set<Alert>();
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder mb)
{
// Trader
mb.Entity<Trader>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.Platform, t.PlatformUserId }).IsUnique();
e.Property(t => t.PlatformUserId).HasMaxLength(128);
e.Property(t => t.DisplayName).HasMaxLength(256);
e.Property(t => t.TotalPnl).HasPrecision(18, 4);
e.Property(t => t.WinRate).HasPrecision(8, 4);
e.HasOne(t => t.CurrentScore).WithOne(s => s.Trader)
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// Trade
mb.Entity<Trade>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.Platform, t.PlatformTradeId }).IsUnique();
e.HasIndex(t => t.TraderId);
e.HasIndex(t => t.ExecutedAt);
e.HasIndex(t => t.AssetId);
e.HasIndex(t => t.DbMarketId);
// PlatformTradeId: legacy data up to 256 chars; new trades use shorter format
e.Property(t => t.PlatformTradeId).HasMaxLength(256);
// MarketId: Polymarket ConditionId is always 66 hex chars
e.Property(t => t.MarketId).HasMaxLength(66);
// AssetId: clobTokenId; Polymarket uses decimal strings up to 78 chars
e.Property(t => t.AssetId).HasMaxLength(80);
// Outcome: labels can be long (e.g. anime titles or sports match descriptions)
e.Property(t => t.Outcome).HasMaxLength(128);
// Price: 0.001.00 on prediction markets, 6 decimals sufficient
e.Property(t => t.Price).HasPrecision(10, 6);
// Size: number of shares, needs more integer digits
e.Property(t => t.Size).HasPrecision(14, 6);
e.Property(t => t.Amount).HasPrecision(18, 4);
// TransactionHash: 0x + 64 hex = 66 chars
e.Property(t => t.TransactionHash).HasMaxLength(66);
e.HasOne(t => t.Trader).WithMany(tr => tr.Trades).HasForeignKey(t => t.TraderId);
e.HasOne(t => t.MarketOutcome).WithMany().HasForeignKey(t => t.MarketOutcomeId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(t => t.DbMarket).WithMany().HasForeignKey(t => t.DbMarketId)
.OnDelete(DeleteBehavior.SetNull);
});
// Market
mb.Entity<Market>(e =>
{
e.HasKey(m => m.Id);
e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique();
e.Property(m => m.PlatformMarketId).HasMaxLength(256);
e.Property(m => m.MarketSlug).HasMaxLength(512);
e.Property(m => m.EventSlug).HasMaxLength(512);
e.Property(m => m.Question).HasMaxLength(1024);
e.Property(m => m.Description).HasMaxLength(4096);
e.Property(m => m.ImageUrl).HasMaxLength(1024);
e.Property(m => m.Category).HasMaxLength(128);
e.Property(m => m.Volume).HasPrecision(18, 4);
e.Property(m => m.Liquidity).HasPrecision(18, 4);
e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId)
.OnDelete(DeleteBehavior.Cascade);
});
// MarketOutcome
mb.Entity<MarketOutcome>(e =>
{
e.HasKey(o => o.Id);
e.HasIndex(o => o.TokenId);
e.HasIndex(o => new { o.MarketId, o.OutcomeIndex }).IsUnique();
e.Property(o => o.Label).HasMaxLength(256);
e.Property(o => o.TokenId).HasMaxLength(256);
e.Property(o => o.CurrentPrice).HasPrecision(18, 8);
});
// TraderScore
mb.Entity<TraderScore>(e =>
{
e.HasKey(s => s.Id);
e.Property(s => s.ActivityScore).HasPrecision(8, 4);
e.Property(s => s.QualityScore).HasPrecision(8, 4);
e.Property(s => s.CombinedScore).HasPrecision(8, 4);
e.Property(s => s.VolumeScore).HasPrecision(8, 4);
e.Property(s => s.TimingScore).HasPrecision(8, 4);
});
// WatchlistEntry
mb.Entity<WatchlistEntry>(e =>
{
e.HasKey(w => w.Id);
e.HasIndex(w => w.TraderId).IsUnique();
e.Property(w => w.Label).HasMaxLength(256);
e.HasOne(w => w.Trader).WithMany(t => t.WatchlistEntries).HasForeignKey(w => w.TraderId);
});
// Alert
mb.Entity<Alert>(e =>
{
e.HasKey(a => a.Id);
e.HasIndex(a => a.CreatedAt);
e.Property(a => a.Title).HasMaxLength(512);
e.Property(a => a.Message).HasMaxLength(4096);
e.HasOne(a => a.Trader).WithMany().HasForeignKey(a => a.TraderId).OnDelete(DeleteBehavior.SetNull);
});
// PlatformConfig
mb.Entity<PlatformConfig>(e =>
{
e.HasKey(p => p.Id);
e.Property(p => p.Name).HasMaxLength(128);
e.Property(p => p.DisplayName).HasMaxLength(256);
e.Property(p => p.BaseUrl).HasMaxLength(1024);
});
// TraderAnalytics
mb.Entity<TraderAnalytics>(e =>
{
e.HasKey(a => a.TraderId);
e.Property(a => a.OverallPnL).HasPrecision(18, 4);
e.Property(a => a.OverallWinRate).HasPrecision(8, 4);
e.Property(a => a.PnL30d).HasPrecision(18, 4);
e.Property(a => a.WinRate30d).HasPrecision(8, 4);
e.Property(a => a.PnL7d).HasPrecision(18, 4);
e.Property(a => a.WinRate7d).HasPrecision(8, 4);
e.Property(a => a.PnL24h).HasPrecision(18, 4);
e.Property(a => a.WinRate24h).HasPrecision(8, 4);
});
// MarketAnalytics
mb.Entity<MarketAnalytics>(e =>
{
e.HasKey(a => a.MarketId);
e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
});
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using System.IO;
namespace Predictalytics.Infrastructure.Data;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
// Fallback for local migrations
var connectionString = "Server=localhost;Database=Predictalytics;User=root;Password=;";
optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 31)));
return new AppDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,30 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class AlertRepository : IAlertRepository
{
private readonly AppDbContext _db;
public AlertRepository(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var q = _db.Alerts.AsQueryable();
if (unreadOnly) q = q.Where(a => !a.IsRead);
return await q.OrderByDescending(a => a.CreatedAt).Take(count).ToListAsync(ct);
}
public async Task AddAsync(Alert alert, CancellationToken ct = default)
{ _db.Alerts.Add(alert); await _db.SaveChangesAsync(ct); }
public async Task MarkAsReadAsync(int id, CancellationToken ct = default)
{
var a = await _db.Alerts.FindAsync(new object[] { id }, ct);
if (a != null) { a.IsRead = true; await _db.SaveChangesAsync(ct); }
}
public async Task<int> GetUnreadCountAsync(CancellationToken ct = default)
=> await _db.Alerts.CountAsync(a => !a.IsRead, ct);
}
@@ -0,0 +1,193 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using System.Threading;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class MarketRepository : IMarketRepository
{
private readonly AppDbContext _db;
private static readonly SemaphoreSlim _syncSemaphore = new(1, 1);
public MarketRepository(AppDbContext db) => _db = db;
public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default)
=> await _db.Markets.Include(m => m.Outcomes)
.FirstOrDefaultAsync(m => m.Platform == platform && m.PlatformMarketId == platformMarketId, ct);
public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default)
=> await _db.MarketOutcomes.Include(o => o.Market)
.FirstOrDefaultAsync(o => o.TokenId == tokenId, ct);
public async Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default)
=> await _db.MarketOutcomes.Include(o => o.Market)
.Where(o => tokenIds.Contains(o.TokenId))
.ToListAsync(ct);
public async Task AddOrUpdateAsync(Market market, CancellationToken ct = default)
{
TruncateMarketStrings(market);
var existing = await _db.Markets.Include(m => m.Outcomes)
.FirstOrDefaultAsync(m => m.Platform == market.Platform && m.PlatformMarketId == market.PlatformMarketId, ct);
if (existing != null)
{
UpdateMarketFields(existing, market);
}
else
{
_db.Markets.Add(market);
}
await _db.SaveChangesAsync(ct);
}
public async Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default)
{
// Deduplicate input by PlatformMarketId to avoid processing the same ID twice in one call
var marketList = markets
.GroupBy(m => new { m.Platform, m.PlatformMarketId })
.Select(g => g.First())
.ToList();
if (!marketList.Any()) return;
await _syncSemaphore.WaitAsync(ct);
try
{
// Process in sub-batches to avoid too large SQL queries
const int subBatchSize = 500;
for (int i = 0; i < marketList.Count; i += subBatchSize)
{
var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList();
var platform = currentBatch.First().Platform;
var ids = currentBatch.Select(m => m.PlatformMarketId).ToList();
// Fetch all existing markets in this batch at once
var existingMarkets = await _db.Markets.Include(m => m.Outcomes)
.Where(m => m.Platform == platform && ids.Contains(m.PlatformMarketId))
.ToListAsync(ct);
var existingMap = existingMarkets.ToDictionary(m => m.PlatformMarketId);
foreach (var market in currentBatch)
{
TruncateMarketStrings(market);
if (existingMap.TryGetValue(market.PlatformMarketId, out var existing))
{
UpdateMarketFields(existing, market);
}
else
{
_db.Markets.Add(market);
}
}
await _db.SaveChangesAsync(ct);
}
}
finally
{
_syncSemaphore.Release();
}
}
private void UpdateMarketFields(Market existing, Market updated)
{
existing.Question = updated.Question;
existing.MarketSlug = updated.MarketSlug;
existing.EventSlug = updated.EventSlug;
existing.Description = updated.Description;
existing.ImageUrl = updated.ImageUrl;
existing.Category = updated.Category;
existing.Volume = updated.Volume;
existing.Liquidity = updated.Liquidity;
existing.StartDate = updated.StartDate;
existing.EndDate = updated.EndDate;
existing.IsResolved = updated.IsResolved;
existing.ResolutionOutcome = updated.ResolutionOutcome;
existing.CreatedAt = updated.CreatedAt; // Platform creation date
existing.LastUpdatedAt = DateTime.UtcNow;
// Upsert outcomes
foreach (var newOutcome in updated.Outcomes)
{
var existingOutcome = existing.Outcomes
.FirstOrDefault(o => o.OutcomeIndex == newOutcome.OutcomeIndex);
if (existingOutcome != null)
{
existingOutcome.Label = newOutcome.Label;
existingOutcome.TokenId = newOutcome.TokenId;
existingOutcome.CurrentPrice = newOutcome.CurrentPrice;
}
else
{
newOutcome.MarketId = existing.Id;
existing.Outcomes.Add(newOutcome);
}
}
}
private void TruncateMarketStrings(Market market)
{
market.Question = StringHelper.Truncate(market.Question, 1024) ?? "";
market.Description = StringHelper.Truncate(market.Description, 4096);
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
market.EventSlug = StringHelper.Truncate(market.EventSlug, 512) ?? "";
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
market.Category = StringHelper.Truncate(market.Category, 128) ?? "";
foreach (var o in market.Outcomes)
{
o.Label = StringHelper.Truncate(o.Label, 256) ?? "";
}
}
public async Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default)
=> await _db.Markets.Include(m => m.Outcomes)
.Where(m => !m.IsResolved)
.OrderByDescending(m => m.Volume)
.Take(count)
.ToListAsync(ct);
public async Task<int> GetCountAsync(CancellationToken ct = default)
=> await _db.Markets.CountAsync(ct);
public async Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default)
{
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
return await _db.Markets
.Where(m => !m.IsResolved && (m.LastTradesUpdatedAt == null || m.LastTradesUpdatedAt < cutoff))
.OrderBy(m => m.LastTradesUpdatedAt ?? DateTime.MinValue)
.Take(limit)
.ToListAsync(ct);
}
public async Task UpdateAsync(Market market, CancellationToken ct = default)
{
TruncateMarketStrings(market);
_db.Markets.Update(market);
await _db.SaveChangesAsync(ct);
}
public async Task<Market?> GetByIdAsync(int id, CancellationToken ct = default)
=> await _db.Markets.Include(m => m.Outcomes).FirstOrDefaultAsync(m => m.Id == id, ct);
public async Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Market>();
return await _db.Markets.Include(m => m.Outcomes)
.Where(m => m.Question.Contains(query) ||
m.PlatformMarketId.Contains(query) ||
m.Id.ToString() == query)
.OrderByDescending(m => m.Volume)
.Take(take)
.ToListAsync(ct);
}
}
@@ -0,0 +1,111 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class TradeRepository : ITradeRepository
{
private readonly AppDbContext _db;
public TradeRepository(AppDbContext db) => _db = db;
public async Task<Trade?> GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default)
=> await _db.Trades.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformTradeId == platformTradeId, ct);
public async Task<IReadOnlyList<Trade>> GetByTraderIdAsync(int traderId, int skip = 0, int take = 50, CancellationToken ct = default)
=> await _db.Trades.Include(t => t.Trader).Where(t => t.TraderId == traderId)
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
public async Task<IReadOnlyList<Trade>> GetByDbMarketIdAsync(int dbMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
=> await _db.Trades.Include(t => t.Trader).Where(t => t.DbMarketId == dbMarketId)
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
public async Task<IReadOnlyList<Trade>> GetByMarketIdAsync(string platformMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
=> await _db.Trades.Include(t => t.Trader).Where(t => t.MarketId == platformMarketId)
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
public async Task<IReadOnlyList<Trade>> GetRecentAsync(int count = 50, PlatformType? platform = null, CancellationToken ct = default)
{
var q = _db.Trades.Include(t => t.Trader).AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
return await q.OrderByDescending(t => t.ExecutedAt).Take(count).ToListAsync(ct);
}
public async Task<IReadOnlyList<Trade>> GetLargestAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
{
var q = _db.Trades.Include(t => t.Trader).AsQueryable();
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
return await q.OrderByDescending(t => t.Amount).Take(count).ToListAsync(ct);
}
public async Task<int> GetCountAsync(int? traderId = null, CancellationToken ct = default)
{
var q = _db.Trades.AsQueryable();
if (traderId.HasValue) q = q.Where(t => t.TraderId == traderId.Value);
return await q.CountAsync(ct);
}
public async Task AddRangeAsync(IEnumerable<Trade> trades, CancellationToken ct = default)
{
foreach (var t in trades)
{
t.Outcome = StringHelper.Truncate(t.Outcome, 128) ?? "";
t.PlatformTradeId = StringHelper.Truncate(t.PlatformTradeId, 256) ?? "";
t.MarketId = StringHelper.Truncate(t.MarketId, 66) ?? "";
t.AssetId = StringHelper.Truncate(t.AssetId, 80) ?? "";
if (t.TransactionHash != null)
t.TransactionHash = StringHelper.Truncate(t.TransactionHash, 66);
}
try
{
_db.Trades.AddRange(trades);
await _db.SaveChangesAsync(ct);
}
catch
{
foreach (var t in trades)
{
try { _db.Entry(t).State = EntityState.Detached; } catch { }
}
throw;
}
}
public async Task<decimal> GetTotalVolumeAsync(DateTime? since = null, CancellationToken ct = default)
{
var q = _db.Trades.AsQueryable();
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
return await q.SumAsync(t => t.Amount, ct);
}
public async Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default)
{
return await _db.Trades
.Where(t => t.MarketOutcomeId == null && !string.IsNullOrEmpty(t.AssetId))
.OrderByDescending(t => t.ExecutedAt)
.Take(limit)
.ToListAsync(ct);
}
public async Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, CancellationToken ct = default)
{
var ids = await _db.Trades
.Where(t => t.Platform == platform && t.TraderId == traderId)
.Select(t => t.PlatformTradeId)
.ToListAsync(ct);
return new HashSet<string>(ids);
}
public async Task UpdateAsync(Trade trade, CancellationToken ct = default)
{
trade.Outcome = StringHelper.Truncate(trade.Outcome, 128) ?? "";
trade.PlatformTradeId = StringHelper.Truncate(trade.PlatformTradeId, 256) ?? "";
trade.MarketId = StringHelper.Truncate(trade.MarketId, 66) ?? "";
trade.AssetId = StringHelper.Truncate(trade.AssetId, 80) ?? "";
_db.Trades.Update(trade);
await _db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,117 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class TraderRepository : ITraderRepository
{
private readonly AppDbContext _db;
public TraderRepository(AppDbContext db) => _db = db;
public async Task<Trader?> GetByIdAsync(int id, CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).FirstOrDefaultAsync(t => t.Id == id, ct);
public async Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore)
.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformUserId == platformUserId, ct);
public async Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default)
{
var q = _db.Traders
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
// Sort by CombinedScore, then by PnL as fallback
return await q.OrderByDescending(t => t.CurrentScore != null ? t.CurrentScore.CombinedScore : 0)
.ThenByDescending(t => t.TotalPnl)
.Skip(skip).Take(take).ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries)
.Where(t => t.WatchlistEntries.Any()).ToListAsync(ct);
public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
.OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct);
public async Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
{
var q = _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics).AsQueryable();
// If 'since' is 7 days ago, try to use PnL7d from Analytics
if (since.HasValue && (DateTime.UtcNow - since.Value).TotalDays >= 6.9)
{
return await q.OrderByDescending(t => t.Analytics != null ? t.Analytics.PnL7d : t.TotalPnl)
.Take(count).ToListAsync(ct);
}
return await q.OrderByDescending(t => t.TotalPnl).Take(count).ToListAsync(ct);
}
public async Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default)
{
var q = _db.Traders.AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
return await q.CountAsync(ct);
}
public async Task AddAsync(Trader trader, CancellationToken ct = default)
{ _db.Traders.Add(trader); await _db.SaveChangesAsync(ct); }
public async Task UpdateAsync(Trader trader, CancellationToken ct = default)
{ _db.Traders.Update(trader); await _db.SaveChangesAsync(ct); }
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var t = await _db.Traders.FindAsync(new object[] { id }, ct);
if (t != null) { _db.Traders.Remove(t); await _db.SaveChangesAsync(ct); }
}
public async Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 12, int take = 20, CancellationToken ct = default)
{
// Prioritize:
// 1. Traders needing initial import (IsInitialImportComplete == false)
// 2. Traders where LastTradesUpdatedAt < cutoff (cooldownHours)
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
return await _db.Traders
.Where(t => !t.IsInitialImportComplete || t.LastTradesUpdatedAt == null || t.LastTradesUpdatedAt < cutoff)
.OrderBy(t => t.IsInitialImportComplete) // false (0) comes before true (1)
.ThenBy(t => t.LastTradesUpdatedAt ?? DateTime.MinValue) // Oldest first
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default)
{
return await _db.Traders
.Where(t => (t.LastPolledAt != null && t.LastPolledAt < inactiveSince) ||
(t.LastApiErrorAt != null && t.LastApiErrorAt < errorSince))
.OrderBy(t => t.LastApiErrorAt ?? DateTime.MaxValue) // Prioritize errors first
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Trader>();
return await _db.Traders
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.Where(t => t.DisplayName.Contains(query) ||
t.PlatformUserId.Contains(query) ||
t.Id.ToString() == query)
.OrderByDescending(t => t.CurrentScore != null ? t.CurrentScore.CombinedScore : 0)
.ThenByDescending(t => t.TotalPnl)
.Take(take)
.ToListAsync(ct);
}
}
@@ -0,0 +1,26 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class WatchlistRepository : IWatchlistRepository
{
private readonly AppDbContext _db;
public WatchlistRepository(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default)
=> await _db.WatchlistEntries.Include(w => w.Trader).ToListAsync(ct);
public async Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default)
=> await _db.WatchlistEntries.FirstOrDefaultAsync(w => w.TraderId == traderId, ct);
public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default)
{ _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); }
public async Task RemoveAsync(int id, CancellationToken ct = default)
{
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
if (e != null) { _db.WatchlistEntries.Remove(e); await _db.SaveChangesAsync(ct); }
}
}
@@ -0,0 +1,10 @@
namespace Predictalytics.Infrastructure.Data;
public static class StringHelper
{
public static string? Truncate(string? value, int maxLength)
{
if (string.IsNullOrEmpty(value)) return value;
return value.Length <= maxLength ? value : value[..maxLength];
}
}
@@ -0,0 +1,136 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Data.Repositories;
using Predictalytics.Infrastructure.Providers.Azuro;
using Predictalytics.Infrastructure.Providers.Limitless;
using Predictalytics.Infrastructure.Providers.Polymarket;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Predictalytics.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddPredictalytics(this IServiceCollection services, IConfiguration configuration, string? connectionStringOverride = null, bool dbDebug = false)
{
if (dbDebug) Serilog.Log.Warning(">>> INFRASTRUCTURE: AddPredictalytics STARTING");
// MySQL / EF Core
var connectionString = connectionStringOverride;
if (string.IsNullOrWhiteSpace(connectionString))
{
connectionString = configuration.GetConnectionString("DefaultConnection")
?? "Server=localhost;Database=Predictalytics_dev;User=root;Password=;";
}
// Use MySqlConnectionStringBuilder to ensure valid format and parse components
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(connectionString);
// Final safety check
if (string.IsNullOrWhiteSpace(csBuilder.Database))
{
Serilog.Log.Error("❌ INVALID CONNECTION STRING: Database name is empty! (Input length: {Length})", connectionString.Length);
throw new InvalidOperationException("The connection string is missing a valid 'Database' parameter.");
}
var maskedCs = csBuilder.ConnectionString.Replace(csBuilder.Password, "****");
if (dbDebug)
{
Serilog.Log.Warning("🗄️ Initializing database connection: {ConnectionString}", maskedCs);
Serilog.Log.Warning("🗄️ Target Server: {Server}, Database: {Database}", csBuilder.Server, csBuilder.Database);
}
// Explicitly register the connection string so we can use it elsewhere if needed
services.AddSingleton(csBuilder.ConnectionString);
services.AddDbContext<AppDbContext>(options =>
{
// Log exactly what is being used at the moment of configuration
if (dbDebug) Serilog.Log.Warning("🛠️ EF: Configuring AppDbContext. Target DB: '{Database}'", csBuilder.Database);
options.UseMySql(csBuilder.ConnectionString, new MySqlServerVersion(new Version(8, 0, 31)),
mysql => mysql.EnableRetryOnFailure(3, TimeSpan.FromSeconds(10), null));
});
// Repositories
services.AddScoped<ITraderRepository, TraderRepository>();
services.AddScoped<ITradeRepository, TradeRepository>();
services.AddScoped<IMarketRepository, MarketRepository>();
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
services.AddScoped<IAlertRepository, AlertRepository>();
// Application Services
services.AddScoped<IScoringService, ScoringService>();
services.AddScoped<IDiscoveryService, DiscoveryService>();
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAnalyticsService, AnalyticsService>();
services.AddScoped<WatchlistService>();
services.AddSingleton<IRateLimiter, RateLimiterService>();
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
// Platform Providers
services.AddHttpClient();
services.AddHttpClient("LimitlessApi", c =>
{
c.BaseAddress = new Uri("https://api.limitless.exchange/");
c.DefaultRequestHeaders.Add("Accept", "application/json");
c.Timeout = TimeSpan.FromSeconds(60);
});
services.AddSingleton<PolymarketApiClient>();
services.AddSingleton<LimitlessApiClient>();
services.AddSingleton<IPlatformProvider, PolymarketProvider>();
services.AddSingleton<IPlatformProvider, LimitlessProvider>();
services.AddSingleton<IPlatformProvider, AzuroProvider>();
return services;
}
/// <summary>
/// Applies pending EF Core migrations and seeds default platform rows.
/// Requires the target database to already be "stamped" with the InitialBaseline
/// migration in __EFMigrationsHistory (see UMSETZUNGSPLAN.md, section B1) if it was
/// previously created via the old EnsureCreated + manual ALTER approach.
/// </summary>
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
{
if (dbDebug)
{
var maskedConnStr = System.Text.RegularExpressions.Regex.Replace(
db.Database.GetDbConnection().ConnectionString ?? "NULL", "Password=[^;]+", "Password=****");
Serilog.Log.Warning("🔍 DEBUG: Applying EF Core migrations. ConnectionString: {CS}", maskedConnStr);
}
await db.Database.MigrateAsync();
if (dbDebug) Serilog.Log.Warning("✅ DEBUG: Migrations applied successfully.");
// Seed default platform rows (idempotent)
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync();
using var seedPlatform = conn.CreateCommand();
seedPlatform.CommandText = @"
INSERT IGNORE INTO `PlatformConfigs` (`Id`, `Name`, `DisplayName`, `IsActive`, `CreatedAt`, `UpdatedAt`) VALUES
(0, 'Unknown', 'Unknown Platform', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
(1, 'Polymarket', 'Polymarket', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
(2, 'Azuro', 'Azuro', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
(3, 'Limitless', 'Limitless', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP());";
await seedPlatform.ExecuteNonQueryAsync();
await conn.CloseAsync();
}
catch (Exception ex)
{
Serilog.Log.Warning("⚠️ Could not connect to database or apply migrations: {Message}. Background workers will retry connection automatically.", ex.Message);
}
}
}
@@ -0,0 +1,19 @@
using Serilog.Context;
namespace Predictalytics.Infrastructure.Logging;
/// <summary>
/// Helper to push platform context into Serilog LogContext for platform-filtered file sinks.
/// Usage: using (PlatformLogContext.Push("Polymarket")) { ... }
/// </summary>
public static class PlatformLogContext
{
/// <summary>
/// Pushes the platform name to the Serilog LogContext.
/// Dispose the returned IDisposable to remove it.
/// </summary>
public static IDisposable Push(string platformName)
{
return LogContext.PushProperty("Platform", platformName);
}
}
@@ -0,0 +1,27 @@
using Serilog.Core;
using Serilog.Events;
namespace Predictalytics.Infrastructure.Logging;
/// <summary>
/// Custom Serilog sink that delegates log writes to a provided action.
/// The action is responsible for marshaling to the correct thread (e.g. UI thread).
/// </summary>
public class RichTextBoxSink : ILogEventSink
{
private readonly Action<string, LogEventLevel> _writeAction;
public RichTextBoxSink(Action<string, LogEventLevel> writeAction)
{
_writeAction = writeAction;
}
public void Emit(LogEvent logEvent)
{
var message = $"[{logEvent.Timestamp:HH:mm:ss}] [{logEvent.Level.ToString()[..3].ToUpper()}] {logEvent.RenderMessage()}";
if (logEvent.Exception != null)
message += $"\n ⚠ {logEvent.Exception.Message}";
_writeAction(message + "\n", logEvent.Level);
}
}
@@ -0,0 +1,641 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260701102311_InitialBaseline")]
partial class InitialBaseline
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CurrentScore");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,431 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialBaseline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Markets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformMarketId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketSlug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EventSlug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ImageUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Question = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Category = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Volume = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Liquidity = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
StartDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
EndDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsResolved = table.Column<bool>(type: "tinyint(1)", nullable: false),
ResolutionOutcome = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DbCreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
LastTradesUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Markets", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PlatformConfigs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DisplayName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
BaseUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SettingsJson = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlatformConfigs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Traders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformUserId = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DisplayName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Notes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
IsAutoDiscovered = table.Column<bool>(type: "tinyint(1)", nullable: false),
Tier = table.Column<int>(type: "int", nullable: false),
Strategy = table.Column<int>(type: "int", nullable: false),
IsSuspectedBot = table.Column<bool>(type: "tinyint(1)", nullable: false),
ManualPriorityOverride = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastPolledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
LastTradesUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsInitialImportComplete = table.Column<bool>(type: "tinyint(1)", nullable: false),
LastApiErrorAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
TotalPnl = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
TotalTrades = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Traders", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "MarketAnalytics",
columns: table => new
{
MarketId = table.Column<int>(type: "int", nullable: false),
LastCalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
BotActivityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
UniqueTradersCount = table.Column<int>(type: "int", nullable: false),
AverageTradeSize = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MarketAnalytics", x => x.MarketId);
table.ForeignKey(
name: "FK_MarketAnalytics_Markets_MarketId",
column: x => x.MarketId,
principalTable: "Markets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "MarketOutcomes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
MarketId = table.Column<int>(type: "int", nullable: false),
Label = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
OutcomeIndex = table.Column<int>(type: "int", nullable: false),
TokenId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CurrentPrice = table.Column<decimal>(type: "decimal(18,8)", precision: 18, scale: 8, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MarketOutcomes", x => x.Id);
table.ForeignKey(
name: "FK_MarketOutcomes_Markets_MarketId",
column: x => x.MarketId,
principalTable: "Markets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Alerts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Type = table.Column<int>(type: "int", nullable: false),
Platform = table.Column<int>(type: "int", nullable: false),
TraderId = table.Column<int>(type: "int", nullable: true),
Title = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Message = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Severity = table.Column<int>(type: "int", nullable: false),
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Alerts", x => x.Id);
table.ForeignKey(
name: "FK_Alerts_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TraderAnalytics",
columns: table => new
{
TraderId = table.Column<int>(type: "int", nullable: false),
LastCalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
OverallPnL = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
OverallWinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
PnL30d = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate30d = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
PnL7d = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate7d = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
PnL24h = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate24h = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderAnalytics", x => x.TraderId);
table.ForeignKey(
name: "FK_TraderAnalytics_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TraderScores",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
ActivityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
QualityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
CombinedScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
VolumeScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
TimingScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
Rank = table.Column<int>(type: "int", nullable: false),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderScores", x => x.Id);
table.ForeignKey(
name: "FK_TraderScores_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "WatchlistEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
Label = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Notes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
AlertsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
AddedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WatchlistEntries", x => x.Id);
table.ForeignKey(
name: "FK_WatchlistEntries_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Trades",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformTradeId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketId = table.Column<string>(type: "varchar(66)", maxLength: 66, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DbMarketId = table.Column<int>(type: "int", nullable: true),
AssetId = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketOutcomeId = table.Column<int>(type: "int", nullable: true),
Outcome = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<int>(type: "int", nullable: false),
Price = table.Column<decimal>(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false),
Size = table.Column<decimal>(type: "decimal(14,6)", precision: 14, scale: 6, nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
ExecutedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
TransactionHash = table.Column<string>(type: "varchar(66)", maxLength: 66, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_Trades", x => x.Id);
table.ForeignKey(
name: "FK_Trades_MarketOutcomes_MarketOutcomeId",
column: x => x.MarketOutcomeId,
principalTable: "MarketOutcomes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Trades_Markets_DbMarketId",
column: x => x.DbMarketId,
principalTable: "Markets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Trades_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Alerts_CreatedAt",
table: "Alerts",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Alerts_TraderId",
table: "Alerts",
column: "TraderId");
migrationBuilder.CreateIndex(
name: "IX_MarketOutcomes_MarketId_OutcomeIndex",
table: "MarketOutcomes",
columns: new[] { "MarketId", "OutcomeIndex" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MarketOutcomes_TokenId",
table: "MarketOutcomes",
column: "TokenId");
migrationBuilder.CreateIndex(
name: "IX_Markets_Platform_PlatformMarketId",
table: "Markets",
columns: new[] { "Platform", "PlatformMarketId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Traders_Platform_PlatformUserId",
table: "Traders",
columns: new[] { "Platform", "PlatformUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TraderScores_TraderId",
table: "TraderScores",
column: "TraderId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Trades_AssetId",
table: "Trades",
column: "AssetId");
migrationBuilder.CreateIndex(
name: "IX_Trades_DbMarketId",
table: "Trades",
column: "DbMarketId");
migrationBuilder.CreateIndex(
name: "IX_Trades_ExecutedAt",
table: "Trades",
column: "ExecutedAt");
migrationBuilder.CreateIndex(
name: "IX_Trades_MarketOutcomeId",
table: "Trades",
column: "MarketOutcomeId");
migrationBuilder.CreateIndex(
name: "IX_Trades_Platform_PlatformTradeId",
table: "Trades",
columns: new[] { "Platform", "PlatformTradeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Trades_TraderId",
table: "Trades",
column: "TraderId");
migrationBuilder.CreateIndex(
name: "IX_WatchlistEntries_TraderId",
table: "WatchlistEntries",
column: "TraderId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Alerts");
migrationBuilder.DropTable(
name: "MarketAnalytics");
migrationBuilder.DropTable(
name: "PlatformConfigs");
migrationBuilder.DropTable(
name: "TraderAnalytics");
migrationBuilder.DropTable(
name: "TraderScores");
migrationBuilder.DropTable(
name: "Trades");
migrationBuilder.DropTable(
name: "WatchlistEntries");
migrationBuilder.DropTable(
name: "MarketOutcomes");
migrationBuilder.DropTable(
name: "Traders");
migrationBuilder.DropTable(
name: "Markets");
}
}
}
@@ -0,0 +1,638 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CurrentScore");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Infrastructure</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Azuro;
/// <summary>
/// Placeholder provider for Azuro prediction market.
/// All log entries include Platform=Azuro for log-file routing.
/// </summary>
public class AzuroProvider : IPlatformProvider
{
private readonly ILogger<AzuroProvider> _logger;
public AzuroProvider(ILogger<AzuroProvider> logger) => _logger = logger;
public PlatformType Platform => PlatformType.Azuro;
public string PlatformName => "Azuro";
public bool IsImplemented => false;
public Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
public Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<TraderPositionInfo>>(Array.Empty<TraderPositionInfo>()); }
public Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
public Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<Market?>(null); }
public Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Market>>(Array.Empty<Market>()); }
public Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
public Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
}
@@ -0,0 +1,96 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Limitless;
/// <summary>
/// HTTP client for Limitless API.
/// Base URL: https://api.limitless.exchange
/// </summary>
public class LimitlessApiClient
{
private readonly HttpClient _client;
private readonly ILogger<LimitlessApiClient> _logger;
public LimitlessApiClient(IHttpClientFactory httpFactory, ILogger<LimitlessApiClient> logger)
{
_client = httpFactory.CreateClient("LimitlessApi");
_logger = logger;
}
public async Task<List<LimitlessMarketResponse>> GetActiveMarketsAsync(int limit = 100, int offset = 0, CancellationToken ct = default)
{
var url = $"markets/active?limit={Math.Min(limit, 25)}"; // Offset is not supported by this endpoint, limit max 25
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Limitless API 400/Error for {Url}: {Error}", url, error);
return [];
}
var result = await response.Content.ReadFromJsonAsync<LimitlessActiveMarketsResponse>(cancellationToken: ct);
return result?.Data ?? [];
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch active markets from Limitless via {Url}", url);
return [];
}
}
public async Task<LimitlessMarketResponse?> GetMarketAsync(string addressOrSlug, CancellationToken ct = default)
{
var url = $"markets/{addressOrSlug}";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<LimitlessMarketResponse>(cancellationToken: ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch Limitless market: {Url}", url);
return null;
}
}
public async Task<LimitlessPortfolioResponse?> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
{
var url = $"portfolio/{walletAddress}/positions";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Limitless API Error for {Url}: {Error}", url, error);
return null;
}
return await response.Content.ReadFromJsonAsync<LimitlessPortfolioResponse>(cancellationToken: ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch Limitless positions for {Url}", url);
return null;
}
}
public async Task<List<LimitlessEventResponse>> GetMarketEventsAsync(string slug, int limit = 50, CancellationToken ct = default)
{
var url = $"markets/{slug}/events?limit={limit}";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return [];
var result = await response.Content.ReadFromJsonAsync<LimitlessEventsResponse>(cancellationToken: ct);
return result?.Events ?? [];
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch events for Limitless market {Url}", url);
return [];
}
}
}
@@ -0,0 +1,171 @@
using System.Text.Json.Serialization;
namespace Predictalytics.Infrastructure.Providers.Limitless;
public class LimitlessActiveMarketsResponse
{
[JsonPropertyName("data")]
public List<LimitlessMarketResponse>? Data { get; set; }
[JsonPropertyName("totalMarketsCount")]
public int TotalMarketsCount { get; set; }
}
public class LimitlessEventsResponse
{
[JsonPropertyName("events")]
public List<LimitlessEventResponse>? Events { get; set; }
}
public class LimitlessPortfolioResponse
{
[JsonPropertyName("clob")]
public List<LimitlessClobItem>? Clob { get; set; }
[JsonPropertyName("amm")]
public List<LimitlessClobItem>? Amm { get; set; }
[JsonPropertyName("group")]
public List<LimitlessClobItem>? Group { get; set; }
}
public class LimitlessClobItem
{
[JsonPropertyName("market")]
public LimitlessMarketResponse? Market { get; set; }
[JsonPropertyName("positions")]
public LimitlessPositionsContainer? Positions { get; set; }
}
public class LimitlessPositionsContainer
{
[JsonPropertyName("yes")]
public LimitlessPositionDetails? Yes { get; set; }
[JsonPropertyName("no")]
public LimitlessPositionDetails? No { get; set; }
}
public class LimitlessPositionDetails
{
[JsonPropertyName("size")]
public string? Size { get; set; } // API uses strings for numbers here
[JsonPropertyName("fillPrice")]
public string? FillPrice { get; set; }
[JsonPropertyName("marketValue")]
public string? MarketValue { get; set; }
[JsonPropertyName("realisedPnl")]
public string? RealisedPnl { get; set; }
[JsonPropertyName("unrealizedPnl")]
public string? UnrealizedPnl { get; set; }
[JsonPropertyName("latestTrade")]
public LimitlessTradeInfo? LatestTrade { get; set; }
}
public class LimitlessMarketResponse
{
[JsonPropertyName("address")]
public string? Address { get; set; }
[JsonPropertyName("slug")]
public string? Slug { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("categories")]
public List<string>? Categories { get; set; }
[JsonPropertyName("imageUrl")]
public string? ImageUrl { get; set; }
[JsonPropertyName("expirationDate")]
public string? ExpirationDate { get; set; }
[JsonPropertyName("expirationTimestamp")]
public long? ExpirationTimestamp { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
[JsonPropertyName("closed")]
public bool Closed { get; set; }
[JsonPropertyName("volumeFormatted")]
public string? VolumeFormatted { get; set; }
[JsonPropertyName("liquidity")]
public double? Liquidity { get; set; }
[JsonPropertyName("prices")]
public List<double>? Prices { get; set; }
[JsonPropertyName("tokens")]
public LimitlessTokens? Tokens { get; set; }
}
public class LimitlessTokens
{
[JsonPropertyName("yes")]
public string? Yes { get; set; }
[JsonPropertyName("no")]
public string? No { get; set; }
}
public class LimitlessTradeInfo
{
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
[JsonPropertyName("price")]
public string? Price { get; set; } // Might be string in this context
[JsonPropertyName("size")]
public string? Size { get; set; }
[JsonPropertyName("side")]
public object? Side { get; set; }
[JsonPropertyName("transactionHash")]
public string? TransactionHash { get; set; }
}
public class LimitlessEventResponse
{
[JsonPropertyName("txHash")]
public string? TxHash { get; set; }
[JsonPropertyName("side")]
public object? Side { get; set; }
[JsonPropertyName("price")]
public double? Price { get; set; }
[JsonPropertyName("size")]
public double? Size { get; set; }
[JsonPropertyName("createdAt")]
public string? CreatedAt { get; set; }
[JsonPropertyName("profile")]
public LimitlessProfile? Profile { get; set; }
[JsonPropertyName("asset")]
public string? Asset { get; set; }
}
public class LimitlessProfile
{
[JsonPropertyName("account")]
public string? Account { get; set; }
}
@@ -0,0 +1,277 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Limitless;
/// <summary>
/// Placeholder provider for Limitless prediction market.
/// All log entries include Platform=Limitless for log-file routing.
/// </summary>
public class LimitlessProvider : IPlatformProvider
{
private readonly LimitlessApiClient _api;
private readonly ILogger<LimitlessProvider> _logger;
public PlatformType Platform => PlatformType.Limitless;
public string PlatformName => "Limitless";
public bool IsImplemented => true;
public LimitlessProvider(LimitlessApiClient api, ILogger<LimitlessProvider> logger)
{
_api = api;
_logger = logger;
}
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching latest trade activity via positions for {Wallet}", platformUserId);
var portfolio = await _api.GetPositionsAsync(platformUserId, ct);
if (portfolio == null) return [];
var items = (portfolio.Clob ?? []).Concat(portfolio.Amm ?? []).Concat(portfolio.Group ?? []);
var trades = new List<Trade>();
foreach (var item in items)
{
if (item.Positions == null || item.Market == null) continue;
// Check Yes and No positions for latest trades
var posList = new[] {
(Details: item.Positions.Yes, Outcome: "Yes", TokenId: item.Market.Tokens?.Yes),
(Details: item.Positions.No, Outcome: "No", TokenId: item.Market.Tokens?.No)
};
foreach (var pos in posList)
{
if (pos.Details?.LatestTrade == null) continue;
var lt = pos.Details.LatestTrade;
decimal.TryParse(lt.Price, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var price);
decimal.TryParse(lt.Size, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var size);
trades.Add(new Trade
{
Platform = PlatformType.Limitless,
// Compact format: {txHash}_{assetId} — wallet via TransientWallet
PlatformTradeId = lt.TransactionHash != null
? $"{lt.TransactionHash}_{pos.TokenId}"
: $"{lt.Timestamp}_{pos.TokenId}",
MarketId = item.Market.Address ?? item.Market.Slug ?? "",
AssetId = pos.TokenId ?? "",
Outcome = pos.Outcome,
Side = ParseSide(lt.Side),
Price = price,
Size = size,
Amount = price * size,
ExecutedAt = (lt.Timestamp > 0 && lt.Timestamp < 253402300799)
? DateTimeOffset.FromUnixTimeSeconds(lt.Timestamp).UtcDateTime
: DateTime.UtcNow,
TransactionHash = lt.TransactionHash,
TransientWallet = platformUserId,
});
}
}
return trades.OrderByDescending(t => t.ExecutedAt).Take(limit).ToList();
}
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
var portfolio = await _api.GetPositionsAsync(platformUserId, ct);
if (portfolio == null) return [];
var items = (portfolio.Clob ?? []).Concat(portfolio.Amm ?? []).Concat(portfolio.Group ?? []);
var result = new List<TraderPositionInfo>();
foreach (var item in items)
{
if (item.Positions == null || item.Market == null) continue;
var posList = new[] {
(Details: item.Positions.Yes, Outcome: "Yes", TokenId: item.Market.Tokens?.Yes),
(Details: item.Positions.No, Outcome: "No", TokenId: item.Market.Tokens?.No)
};
foreach (var pos in posList)
{
if (pos.Details == null) continue;
decimal.TryParse(pos.Details.Size, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var size);
if (size == 0) continue;
decimal.TryParse(pos.Details.FillPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var avgPrice);
decimal.TryParse(pos.Details.MarketValue, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var val);
decimal.TryParse(pos.Details.UnrealizedPnl, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var pnl);
result.Add(new TraderPositionInfo(
platformUserId,
item.Market.Address ?? item.Market.Slug ?? "",
item.Market.Title ?? "",
pos.Outcome,
size,
avgPrice,
val,
0 // PercentPnl not directly available as decimal in this view
));
}
}
return result;
}
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogInformation("Trader discovery for Limitless via active markets...");
var markets = await _api.GetActiveMarketsAsync(10, 0, ct);
var traders = new List<DiscoveredTrader>();
foreach (var m in markets.Take(5))
{
if (ct.IsCancellationRequested) break;
var events = await _api.GetMarketEventsAsync(m.Slug ?? m.Address ?? "", 20, ct);
foreach (var e in events.Where(ev => ev.Profile?.Account != null))
{
traders.Add(new DiscoveredTrader(
e.Profile!.Account!,
e.Profile.Account![..10] + "...",
(decimal)(e.Price * e.Size ?? 0),
1, 0
));
}
}
return traders.GroupBy(t => t.PlatformUserId)
.Select(g => g.First())
.Take(limit)
.ToList();
}
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
var raw = await _api.GetMarketAsync(platformMarketId, ct);
if (raw == null) return null;
return MapLimitlessMarket(raw);
}
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
int.TryParse(cursor, out var offset);
// Since Limitless /markets/active doesn't support offset, we only return the first page.
// Returning data for offset > 0 would cause an infinite loop in MarketSyncWorker.
if (offset > 0) return [];
var raw = await _api.GetActiveMarketsAsync(limit, offset, ct);
return raw.Select(MapLimitlessMarket).ToList();
}
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{
var events = await _api.GetMarketEventsAsync(platformMarketId, limit * 2, ct);
return events
.Where(e => e.Profile?.Account != null)
.GroupBy(e => e.Profile!.Account)
.Select(g => new DiscoveredTrader(g.Key!, g.Key![..10] + "...", 0, g.Count(), 0))
.Take(limit)
.ToList();
}
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
{
var events = await _api.GetMarketEventsAsync(platformMarketId, limit, ct);
return events
.Where(e => e.Profile?.Account != null)
.Select(e =>
{
var wallet = e.Profile!.Account!;
var asset = e.Asset ?? "";
var side = e.Side ?? "";
// Compact format: {txHash}_{assetId} — wallet via TransientWallet
var tradeId = e.TxHash != null
? $"{e.TxHash}_{asset}"
: $"{e.CreatedAt}_{asset}_{side}";
return new Trade
{
Platform = PlatformType.Limitless,
PlatformTradeId = tradeId,
MarketId = platformMarketId,
AssetId = asset,
Outcome = "",
Side = ParseSide(side),
Price = (decimal)(e.Price ?? 0),
Size = (decimal)(e.Size ?? 0),
Amount = (decimal)(e.Price * e.Size ?? 0),
ExecutedAt = DateTime.TryParse(e.CreatedAt, out var dt) ? dt : DateTime.UtcNow,
TransactionHash = e.TxHash,
TransientWallet = wallet,
};
})
.ToList();
}
private Market MapLimitlessMarket(LimitlessMarketResponse raw)
{
var market = new Market
{
Platform = PlatformType.Limitless,
PlatformMarketId = raw.Address ?? raw.Slug ?? "",
MarketSlug = raw.Slug ?? "",
EventSlug = "", // Limitless doesn't seem to have a clear Event/Market split in this model
Question = raw.Title ?? "",
Description = raw.Description ?? "",
Category = raw.Categories?.FirstOrDefault() ?? "",
ImageUrl = raw.ImageUrl ?? "",
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
Liquidity = (decimal)(raw.Liquidity ?? 0),
EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799)
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
: (DateTime.TryParse(raw.ExpirationDate, out var ed) ? ed : null),
IsResolved = raw.Closed,
LastUpdatedAt = DateTime.UtcNow
};
// Map Outcomes from tokens object and prices array
if (raw.Tokens != null)
{
// Yes Outcome
market.Outcomes.Add(new MarketOutcome
{
Label = "Yes",
OutcomeIndex = 0,
TokenId = raw.Tokens.Yes ?? "",
CurrentPrice = (decimal)(raw.Prices != null && raw.Prices.Count > 0 ? raw.Prices[0] : 0)
});
// No Outcome
market.Outcomes.Add(new MarketOutcome
{
Label = "No",
OutcomeIndex = 1,
TokenId = raw.Tokens.No ?? "",
CurrentPrice = (decimal)(raw.Prices != null && raw.Prices.Count > 1 ? raw.Prices[1] : 0)
});
}
return market;
}
private TradeSide ParseSide(object? sideObj)
{
var sideStr = sideObj?.ToString()?.ToUpper();
if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy;
return TradeSide.Sell;
}
}
@@ -0,0 +1,164 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
/// <summary>
/// HTTP client for Polymarket Data API.
/// All endpoints use https://data-api.polymarket.com
/// </summary>
public class PolymarketApiClient
{
private readonly HttpClient _client;
private readonly HttpClient _gammaClient;
private readonly IRateLimiter _rateLimiter;
private readonly ILogger<PolymarketApiClient> _logger;
private const string DataApiBase = "https://data-api.polymarket.com";
private const string GammaApiBase = "https://gamma-api.polymarket.com";
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger)
{
_client = httpFactory.CreateClient("PolymarketData");
_client.BaseAddress = new Uri(DataApiBase);
_client.DefaultRequestHeaders.Add("Accept", "application/json");
_gammaClient = httpFactory.CreateClient("PolymarketGamma");
_gammaClient.BaseAddress = new Uri(GammaApiBase);
_gammaClient.DefaultRequestHeaders.Add("Accept", "application/json");
_rateLimiter = rateLimiter;
_logger = logger;
}
public async Task<List<PolymarketTradeResponse>> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default)
{
var url = $"/activity?user={walletAddress}&limit={limit}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
}
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default)
{
var url = $"/trades?condition_id={conditionId}&limit={limit}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
}
public async Task<List<PolymarketPositionResponse>> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
{
var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC";
return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, ct) ?? [];
}
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
{
var url = $"/markets?condition_id={conditionId}";
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
return results?.FirstOrDefault();
}
/// <summary>
/// Fetch a batch of markets from the Gamma API with pagination.
/// Supports offset-based pagination via the offset parameter.
/// </summary>
public async Task<List<GammaMarketResponse>> GetMarketsAsync(int limit = 1000, int offset = 0, bool includeClosed = false, CancellationToken ct = default)
{
var activeOnly = !includeClosed;
var url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
_logger.LogDebug("Fetching markets: {Url}", url);
var result = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
_logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
return result ?? [];
}
/// <summary>
/// Fetch top holders for a specific market (conditionId) from the Data API.
/// Returns holders grouped by token (outcome).
/// </summary>
public async Task<List<HoldersResponse>> GetHoldersAsync(string conditionId, int limit = 20, CancellationToken ct = default)
{
var url = $"/holders?market={conditionId}&limit={limit}";
_logger.LogDebug("Fetching holders: {Url}", url);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, ct);
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
return result ?? [];
}
/// <summary>
/// Get leaderboard from the official Polymarket Data API v1.
/// Endpoint: GET https://data-api.polymarket.com/v1/leaderboard
/// </summary>
public async Task<List<LeaderboardEntry>> GetLeaderboardAsync(
int limit = 50,
string timePeriod = "ALL",
string orderBy = "PNL",
string category = "OVERALL",
CancellationToken ct = default)
{
var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}";
_logger.LogDebug("Fetching leaderboard: {Url}", url);
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, ct);
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
return result ?? [];
}
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, CancellationToken ct, int attempt = 1)
{
try
{
var response = await client.GetAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
TimeSpan? retryAfter = null;
if (response.Headers.RetryAfter != null)
{
retryAfter = response.Headers.RetryAfter.Delta ??
(response.Headers.RetryAfter.Date.HasValue
? response.Headers.RetryAfter.Date.Value - DateTimeOffset.UtcNow
: null);
}
var waitTime = retryAfter ?? TimeSpan.FromSeconds(30);
if (waitTime.TotalSeconds < 5)
{
_logger.LogWarning("Got 429 but Retry-After was {RawWait}s. Enforcing 30s minimum.", waitTime.TotalSeconds);
waitTime = TimeSpan.FromSeconds(30);
}
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime);
if (attempt < 3)
{
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct);
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
return await ExecuteWithRetryAsync<T>(client, url, ct, attempt + 1);
}
return default;
}
if ((int)response.StatusCode == 422)
{
_logger.LogInformation("End of data reached (422) for {Url}. Stopping pagination.", url);
return default;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
}
catch (Exception ex)
{
if (ex is HttpRequestException hex && hex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_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);
return default;
}
}
}
@@ -0,0 +1,199 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
/// <summary>
/// Converter that handles JSON values that may be either a number or a string.
/// Polymarket API is inconsistent — some fields are numbers in one endpoint and strings in another.
/// </summary>
public class FlexibleDoubleConverter : JsonConverter<double>
{
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String => double.TryParse(reader.GetString(), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0,
JsonTokenType.Null => 0,
_ => 0
};
}
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
}
public class FlexibleLongConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt64(),
JsonTokenType.String => long.TryParse(reader.GetString(), out var v) ? v : 0,
_ => 0
};
}
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
}
// ═══════════════════════════════════════════════════════
// Polymarket Data API response models
// ═══════════════════════════════════════════════════════
public class PolymarketTradeResponse
{
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("asset")] public string Asset { get; set; } = "";
[JsonPropertyName("side")] public string Side { get; set; } = "";
[JsonPropertyName("action")] public string Action { get; set; } = "";
[JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("user")] public string? User { get; set; }
[JsonPropertyName("proxyWallet")] public string? ProxyWallet { get; set; }
[JsonPropertyName("size")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Size { get; set; }
[JsonPropertyName("price")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Price { get; set; }
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
[JsonPropertyName("timestamp")]
[JsonConverter(typeof(FlexibleLongConverter))]
public long Timestamp { get; set; }
[JsonPropertyName("transactionHash")] public string? TransactionHash { get; set; }
}
public class PolymarketPositionResponse
{
[JsonPropertyName("asset_id")] public string AssetId { get; set; } = "";
[JsonPropertyName("market")] public string Market { get; set; } = "";
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
[JsonPropertyName("size")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Size { get; set; }
[JsonPropertyName("avgPrice")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double AvgPrice { get; set; }
[JsonPropertyName("currentValue")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double CurrentValue { get; set; }
[JsonPropertyName("cashPnl")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double CashPnl { get; set; }
[JsonPropertyName("percentPnl")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double PercentPnl { get; set; }
[JsonPropertyName("question")] public string Question { get; set; } = "";
}
// ═══════════════════════════════════════════════════════
// Gamma API — Market metadata (full market response)
// ═══════════════════════════════════════════════════════
public class GammaMarketResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("question")] public string Question { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("image")] public string? Image { get; set; }
[JsonPropertyName("category")] public string Category { get; set; } = "";
[JsonPropertyName("groupItemTitle")] public string? GroupItemTitle { get; set; }
[JsonPropertyName("events")] public List<GammaEventResponse>? Events { get; set; }
[JsonPropertyName("volumeNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume { get; set; }
[JsonPropertyName("liquidityNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Liquidity { get; set; }
[JsonPropertyName("endDateIso")] public string? EndDate { get; set; }
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
[JsonPropertyName("closed")] public bool Closed { get; set; }
[JsonPropertyName("active")] public bool Active { get; set; }
[JsonPropertyName("resolved")] public bool Resolved { get; set; }
[JsonPropertyName("resolution_outcome")] public string? ResolutionOutcome { get; set; }
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
/// <summary>JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]"</summary>
[JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; }
/// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary>
[JsonPropertyName("clobTokenIds")] public string? ClobTokenIds { get; set; }
}
public class GammaEventResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
}
// ═══════════════════════════════════════════════════════
// Data API — Holders response
// ═══════════════════════════════════════════════════════
public class HoldersResponse
{
[JsonPropertyName("token")] public string Token { get; set; } = "";
[JsonPropertyName("holders")] public List<HolderEntry> Holders { get; set; } = [];
}
public class HolderEntry
{
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("pseudonym")] public string Pseudonym { get; set; } = "";
[JsonPropertyName("amount")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Amount { get; set; }
[JsonPropertyName("outcomeIndex")] public int OutcomeIndex { get; set; }
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
[JsonPropertyName("verified")] public bool Verified { get; set; }
}
// ═══════════════════════════════════════════════════════
// Polymarket Data API v1 Leaderboard response
// ═══════════════════════════════════════════════════════
public class LeaderboardEntry
{
[JsonPropertyName("rank")] public string Rank { get; set; } = "";
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
[JsonPropertyName("userName")] public string UserName { get; set; } = "";
[JsonPropertyName("vol")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Vol { get; set; }
[JsonPropertyName("pnl")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Pnl { get; set; }
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
[JsonPropertyName("xUsername")] public string? XUsername { get; set; }
[JsonPropertyName("verifiedBadge")] public bool VerifiedBadge { get; set; }
}
@@ -0,0 +1,298 @@
using System.Text.Json;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
/// <summary>
/// Full implementation of IPlatformProvider for Polymarket.
/// All log entries include Platform=Polymarket for log-file routing.
/// </summary>
public class PolymarketProvider : IPlatformProvider
{
private readonly PolymarketApiClient _api;
private readonly ILogger<PolymarketProvider> _logger;
public PlatformType Platform => PlatformType.Polymarket;
public string PlatformName => "Polymarket";
public bool IsImplemented => true;
public PolymarketProvider(PolymarketApiClient api, ILogger<PolymarketProvider> logger)
{ _api = api; _logger = logger; }
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 1000, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching trades for {Wallet} (limit={Limit})", platformUserId, limit);
var raw = await _api.GetTradesAsync(platformUserId, limit, ct);
_logger.LogInformation("Fetched {Count} trades for {Wallet}", raw.Count, platformUserId);
var mappedTrades = raw.Select(r =>
{
var wallet = r.User ?? r.ProxyWallet ?? "";
var side = MapTradeSide(r);
var sideStr = side.ToString().ToUpperInvariant();
// Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size.
// Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker.
return new Trade
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
Side = side,
Price = (decimal)r.Price,
Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash,
TraderId = 0,
TransientWallet = wallet,
};
}).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
}
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default)
{
var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, ct);
_logger.LogInformation("Fetched {Count} trades for Market {Market} (limit={Limit})", raw.Count, platformMarketId, limit);
var mappedTrades = raw.Select(r =>
{
var wallet = !string.IsNullOrEmpty(r.User) ? r.User : (r.ProxyWallet ?? "");
var side = MapTradeSide(r);
var sideStr = side.ToString().ToUpperInvariant();
return new Trade
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
Side = side,
Price = (decimal)r.Price,
Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash,
TraderId = 0,
TransientWallet = wallet,
};
}).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
}
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching positions for {Wallet}", platformUserId);
var raw = await _api.GetPositionsAsync(platformUserId, ct);
_logger.LogInformation("Fetched {Count} positions for {Wallet}", raw.Count, platformUserId);
return raw.Select(r => new TraderPositionInfo(
platformUserId, r.Market, r.Question, r.Outcome,
(decimal)r.Size, (decimal)r.AvgPrice,
(decimal)r.CurrentValue, (decimal)r.PercentPnl
)).ToList();
}
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 50, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogInformation("Running trader discovery via v1/leaderboard (limit={Limit})...", limit);
var leaderboard = await _api.GetLeaderboardAsync(limit, ct: ct);
_logger.LogInformation("Discovery returned {Count} traders from leaderboard", leaderboard.Count);
return leaderboard.Select(e => new DiscoveredTrader(
e.ProxyWallet,
string.IsNullOrEmpty(e.UserName) ? e.ProxyWallet[..10] + "..." : e.UserName,
(decimal)e.Vol,
0, // trade count not in leaderboard API
0 // win rate computed later from trades
)).ToList();
}
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching market {MarketId}", platformMarketId);
var raw = await _api.GetMarketAsync(platformMarketId, ct);
if (raw == null)
{
_logger.LogWarning("Market {MarketId} not found", platformMarketId);
return null;
}
_logger.LogInformation("Fetched market: {Question}", raw.Question);
return MapGammaMarket(raw);
}
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
int offset = 0;
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
offset = parsed;
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct);
_logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count);
return raw
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds))
.Select(MapGammaMarket)
.ToList();
}
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogInformation("Fetching top holders for market {MarketId}", platformMarketId[..12] + "...");
var holdersGroups = await _api.GetHoldersAsync(platformMarketId, limit, ct);
// Flatten all holders across token groups, deduplicate by wallet
var uniqueHolders = holdersGroups
.SelectMany(g => g.Holders)
.GroupBy(h => h.ProxyWallet)
.Select(g =>
{
var first = g.First();
var totalAmount = g.Sum(h => h.Amount);
var displayName = !string.IsNullOrEmpty(first.Name) ? first.Name
: !string.IsNullOrEmpty(first.Pseudonym) ? first.Pseudonym
: first.ProxyWallet[..10] + "...";
return new DiscoveredTrader(
first.ProxyWallet,
displayName,
(decimal)totalAmount,
0, 0
);
})
.OrderByDescending(d => d.Volume24h)
.ToList();
_logger.LogInformation("Discovered {Count} unique holders from market {MarketId}",
uniqueHolders.Count, platformMarketId[..12] + "...");
return uniqueHolders;
}
// ── Private helpers ──────────────────────────────────────────
private Market MapGammaMarket(GammaMarketResponse raw)
{
var eventSlug = "";
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
{
eventSlug = raw.Events[0].Slug;
}
var market = new Market
{
Platform = PlatformType.Polymarket,
PlatformMarketId = raw.ConditionId,
MarketSlug = raw.Slug,
EventSlug = eventSlug,
Description = raw.Description,
ImageUrl = raw.Image,
Question = raw.Question,
Category = raw.Category,
Volume = (decimal)raw.Volume,
Liquidity = (decimal)raw.Liquidity,
StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null,
EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag
ResolutionOutcome = raw.ResolutionOutcome,
LastUpdatedAt = DateTime.UtcNow
};
// Parse outcomes, prices, and token IDs from JSON strings
var outcomeLabels = ParseJsonStringArray(raw.Outcomes);
var outcomePrices = ParseJsonStringArray(raw.OutcomePrices);
var tokenIds = ParseJsonStringArray(raw.ClobTokenIds);
for (int i = 0; i < outcomeLabels.Count; i++)
{
decimal price = 0;
if (i < outcomePrices.Count)
decimal.TryParse(outcomePrices[i], System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out price);
string tokenId = i < tokenIds.Count ? tokenIds[i] : "";
var label = outcomeLabels[i];
if ((label.Equals("Yes", StringComparison.OrdinalIgnoreCase) || label.Equals("No", StringComparison.OrdinalIgnoreCase))
&& !string.IsNullOrEmpty(raw.GroupItemTitle))
{
label = $"{raw.GroupItemTitle} - {label}";
}
market.Outcomes.Add(new MarketOutcome
{
Label = label,
OutcomeIndex = i,
TokenId = tokenId,
CurrentPrice = price
});
}
return market;
}
private static List<string> ParseJsonStringArray(string? json)
{
if (string.IsNullOrEmpty(json)) return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static TradeSide MapTradeSide(PolymarketTradeResponse r)
{
// Check Action/Type field first for special operations
var typeOrAction = !string.IsNullOrEmpty(r.Action) ? r.Action
: !string.IsNullOrEmpty(r.Type) ? r.Type : "";
if (!string.IsNullOrEmpty(typeOrAction))
{
if (typeOrAction.Equals("SPLIT", StringComparison.OrdinalIgnoreCase)) return TradeSide.Split;
if (typeOrAction.Equals("MERGE", StringComparison.OrdinalIgnoreCase)) return TradeSide.Merge;
if (typeOrAction.Equals("REDEEM", StringComparison.OrdinalIgnoreCase)) return TradeSide.Redeem;
if (typeOrAction.Equals("ADD_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.AddLiquidity;
if (typeOrAction.Equals("REMOVE_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.RemoveLiquidity;
// Type field can also contain BUY/SELL directly
if (typeOrAction.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
if (typeOrAction.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
}
// Side field (explicit buy/sell direction)
if (!string.IsNullOrEmpty(r.Side))
{
if (r.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
if (r.Side.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
}
return TradeSide.Unknown;
}
}
@@ -0,0 +1,93 @@
using System.ComponentModel;
using System.Text.Json;
namespace Predictalytics.WinFormsHost;
public class AppSettings
{
private const string FileName = "settings.json";
[Category("Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
[DefaultValue(5000)]
public int WebserverPort { get; set; } = 5000;
[Category("Webserver")]
[DisplayName("Database Debug")]
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
[DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false;
private string _dbServer = "localhost";
private string _dbName = "";
private string _dbUser = "";
private string _dbPassword = "";
[Category("Database")]
[DisplayName("Server")]
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
[Category("Database")]
[DisplayName("Database")]
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
[Category("Database")]
[DisplayName("User")]
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
[Category("Database")]
[DisplayName("Password")]
[PasswordPropertyText(true)]
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
[Browsable(false)]
public string ConnectionString
{
get
{
var builder = new MySqlConnector.MySqlConnectionStringBuilder
{
Server = DbServer?.Trim(),
Database = DbName?.Trim(),
UserID = DbUser?.Trim(),
Password = DbPassword?.Trim(),
AllowPublicKeyRetrieval = true,
SslMode = MySqlConnector.MySqlSslMode.None,
Pooling = true,
MinimumPoolSize = 0,
MaximumPoolSize = 100
};
return builder.ConnectionString;
}
}
public static AppSettings Load()
{
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
if (!File.Exists(filePath))
{
var settings = new AppSettings();
settings.Save();
return settings;
}
try
{
var json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
public void Save()
{
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(this, options);
File.WriteAllText(filePath, json);
}
}
+241
View File
@@ -0,0 +1,241 @@
namespace Predictalytics.WinFormsHost;
partial class MainForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
toolStrip1 = new ToolStrip();
btn_serverstart = new ToolStripButton();
btn_localWebserver = new ToolStripButton();
statusStrip1 = new StatusStrip();
tabControl1 = new TabControl();
tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox();
tabPage2 = new TabPage();
pg_settings = new PropertyGrid();
menuStrip1 = new MenuStrip();
filesToolStripMenuItem = new ToolStripMenuItem();
editToolStripMenuItem = new ToolStripMenuItem();
btn_logfolder = new ToolStripMenuItem();
btn_openbrowser = new ToolStripMenuItem();
developmentToolStripMenuItem = new ToolStripMenuItem();
btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem();
label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout();
tabControl1.SuspendLayout();
tabPage_terminal.SuspendLayout();
tabPage2.SuspendLayout();
menuStrip1.SuspendLayout();
SuspendLayout();
//
// toolStrip1
//
toolStrip1.ImageScalingSize = new Size(24, 24);
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver });
toolStrip1.Location = new Point(0, 33);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(1864, 34);
toolStrip1.TabIndex = 0;
//
// btn_serverstart
//
btn_serverstart.ImageTransparentColor = Color.Magenta;
btn_serverstart.Name = "btn_serverstart";
btn_serverstart.Size = new Size(127, 29);
btn_serverstart.Text = "▶ Start Server";
//
// btn_localWebserver
//
btn_localWebserver.ImageTransparentColor = Color.Magenta;
btn_localWebserver.Name = "btn_localWebserver";
btn_localWebserver.Size = new Size(161, 29);
btn_localWebserver.Text = "▶ Start Webserver";
//
// statusStrip1
//
statusStrip1.ImageScalingSize = new Size(24, 24);
statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_buildVersion });
statusStrip1.Location = new Point(0, 1000);
statusStrip1.Name = "statusStrip1";
statusStrip1.Size = new Size(1864, 32);
statusStrip1.TabIndex = 1;
//
// tabControl1
//
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
tabControl1.Controls.Add(tabPage_terminal);
tabControl1.Controls.Add(tabPage2);
tabControl1.Location = new Point(0, 61);
tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0;
tabControl1.Size = new Size(1864, 946);
tabControl1.TabIndex = 2;
//
// tabPage_terminal
//
tabPage_terminal.Controls.Add(rtb_terminal);
tabPage_terminal.Location = new Point(4, 34);
tabPage_terminal.Name = "tabPage_terminal";
tabPage_terminal.Padding = new Padding(3);
tabPage_terminal.Size = new Size(1856, 908);
tabPage_terminal.TabIndex = 0;
tabPage_terminal.Text = "Terminal";
tabPage_terminal.UseVisualStyleBackColor = true;
//
// rtb_terminal
//
rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
rtb_terminal.Location = new Point(3, 6);
rtb_terminal.Name = "rtb_terminal";
rtb_terminal.Size = new Size(1847, 896);
rtb_terminal.TabIndex = 0;
rtb_terminal.Text = "";
//
// tabPage2
//
tabPage2.Controls.Add(pg_settings);
tabPage2.Location = new Point(4, 34);
tabPage2.Name = "tabPage2";
tabPage2.Padding = new Padding(3);
tabPage2.Size = new Size(1856, 908);
tabPage2.TabIndex = 1;
tabPage2.Text = "Settings";
tabPage2.UseVisualStyleBackColor = true;
//
// pg_settings
//
pg_settings.Location = new Point(3, 6);
pg_settings.Name = "pg_settings";
pg_settings.Size = new Size(1850, 896);
pg_settings.TabIndex = 0;
//
// menuStrip1
//
menuStrip1.ImageScalingSize = new Size(24, 24);
menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem });
menuStrip1.Location = new Point(0, 0);
menuStrip1.Name = "menuStrip1";
menuStrip1.Size = new Size(1864, 33);
menuStrip1.TabIndex = 3;
//
// filesToolStripMenuItem
//
filesToolStripMenuItem.Name = "filesToolStripMenuItem";
filesToolStripMenuItem.Size = new Size(62, 29);
filesToolStripMenuItem.Text = "Files";
//
// editToolStripMenuItem
//
editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser });
editToolStripMenuItem.Name = "editToolStripMenuItem";
editToolStripMenuItem.Size = new Size(58, 29);
editToolStripMenuItem.Text = "Edit";
//
// btn_logfolder
//
btn_logfolder.Name = "btn_logfolder";
btn_logfolder.Text = "Show Logfolder";
btn_logfolder.Click += btn_logfolder_Click;
//
// btn_openbrowser
//
btn_openbrowser.Name = "btn_openbrowser";
btn_openbrowser.Text = "Show Local WebUI";
btn_openbrowser.Click += btn_openbrowser_Click;
//
// developmentToolStripMenuItem
//
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets });
developmentToolStripMenuItem.Name = "developmentToolStripMenuItem";
developmentToolStripMenuItem.Size = new Size(135, 29);
developmentToolStripMenuItem.Text = "Development";
//
// btn_dbReset
//
btn_dbReset.Name = "btn_dbReset";
btn_dbReset.Text = "reset TradesDB";
//
// btn_syncmarkets
//
btn_syncmarkets.Name = "btn_syncmarkets";
btn_syncmarkets.Text = "Sync Marketsa";
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
//
// label_apiRatelimit
//
label_apiRatelimit.Name = "label_apiRatelimit";
label_apiRatelimit.Size = new Size(1670, 25);
label_apiRatelimit.Spring = true;
label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
//
// label_buildVersion
//
label_buildVersion.Name = "label_buildVersion";
label_buildVersion.Size = new Size(179, 25);
label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
//
// MainForm
//
AutoScaleDimensions = new SizeF(10F, 25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(1864, 1032);
Controls.Add(tabControl1);
Controls.Add(statusStrip1);
Controls.Add(toolStrip1);
Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1;
Name = "MainForm";
Text = "Predictalytics";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
statusStrip1.ResumeLayout(false);
statusStrip1.PerformLayout();
tabControl1.ResumeLayout(false);
tabPage_terminal.ResumeLayout(false);
tabPage2.ResumeLayout(false);
menuStrip1.ResumeLayout(false);
menuStrip1.PerformLayout();
ResumeLayout(false);
PerformLayout();
}
#endregion
private ToolStrip toolStrip1;
private ToolStripButton btn_serverstart;
private ToolStripButton btn_localWebserver;
private StatusStrip statusStrip1;
private TabControl tabControl1;
private TabPage tabPage_terminal;
private RichTextBox rtb_terminal;
private TabPage tabPage2;
private MenuStrip menuStrip1;
private ToolStripMenuItem filesToolStripMenuItem;
private ToolStripMenuItem editToolStripMenuItem;
private ToolStripMenuItem btn_logfolder;
private ToolStripMenuItem btn_openbrowser;
private ToolStripMenuItem developmentToolStripMenuItem;
private ToolStripMenuItem btn_dbReset;
private ToolStripMenuItem btn_syncmarkets;
private PropertyGrid pg_settings;
private ToolStripStatusLabel label_apiRatelimit;
private ToolStripStatusLabel label_buildVersion;
}
+203
View File
@@ -0,0 +1,203 @@
using System.Reflection;
using Predictalytics.WinFormsHost.Services;
using Serilog;
namespace Predictalytics.WinFormsHost;
public partial class MainForm : Form
{
private EmbeddedWebServer? _webServer;
private CancellationTokenSource? _workerCts;
private bool _workerRunning;
private bool _webServerRunning;
private AppSettings _settings = null!;
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
public RichTextBox Terminal => rtb_terminal;
public MainForm()
{
InitializeComponent();
this.Text = "Predictalytics Analytics — Backend Server";
rtb_terminal.BackColor = System.Drawing.Color.FromArgb(15, 15, 20);
rtb_terminal.ForeColor = System.Drawing.Color.FromArgb(180, 180, 180);
rtb_terminal.Font = new Font("Cascadia Code", 9.5f, FontStyle.Regular);
rtb_terminal.ReadOnly = true;
}
/// <summary>
/// Called after Serilog is configured. Initializes the embedded web server.
/// </summary>
public void Initialize()
{
_settings = AppSettings.Load();
pg_settings.SelectedObject = _settings;
pg_settings.PropertyValueChanged += (s, e) => {
_settings.Save();
if (_webServer != null)
{
_webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
}
};
_webServer = new EmbeddedWebServer();
_webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
// Build Version (Date of compilation/file creation)
try {
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
} catch {
label_buildVersion.Text = "Build: Unknown";
}
UpdateStatusBar();
// Wire up button events
btn_serverstart.Click += Btn_serverstart_Click;
btn_localWebserver.Click += Btn_localWebserver_Click;
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
Log.Information("MainForm initialized. Ready.");
Log.Information("Press 'Start Server' to begin polling & discovery.");
Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort);
}
private async void Btn_serverstart_Click(object? sender, EventArgs e)
{
if (!_workerRunning)
{
// Start workers
_workerCts = new CancellationTokenSource();
_workerRunning = true;
btn_serverstart.Text = "⏹ Stop Server";
Log.Information("🚀 Starting background workers...");
try
{
_webServer!.ConnectionString = _settings.ConnectionString;
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
await _webServer!.StartWorkersAsync(_workerCts.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex) { Log.Error(ex, "Worker error"); }
}
else
{
// Stop workers
Log.Information("⏹ Stopping background workers...");
_workerCts?.Cancel();
_workerRunning = false;
btn_serverstart.Text = "▶ Start Server";
Log.Information("Workers stopped.");
}
UpdateStatusBar();
}
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
{
if (!_webServerRunning)
{
try
{
Log.Information("🌐 Starting embedded Kestrel webserver on http://localhost:{Port}...", _settings.WebserverPort);
await _webServer!.StartWebServerAsync(_settings.WebserverPort);
_webServerRunning = true;
btn_localWebserver.Text = "⏹ Stop Webserver";
Log.Information("✅ WebUI available at http://localhost:{Port}", _settings.WebserverPort);
Log.Information("📄 Swagger API docs at http://localhost:{Port}/swagger", _settings.WebserverPort);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to start webserver");
_webServerRunning = false;
}
}
else
{
Log.Information("⏹ Stopping webserver...");
await _webServer!.StopWebServerAsync();
_webServerRunning = false;
btn_localWebserver.Text = "▶ Start Webserver";
Log.Information("Webserver stopped.");
}
UpdateStatusBar();
}
private void UpdateStatusBar()
{
var workerStatus = _workerRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
var serverStatus = _webServerRunning ? $"[RUNNING] Webserver :{_settings.WebserverPort}" : "[STOPPED] Webserver";
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
_workerCts?.Cancel();
_webServer?.StopWebServerAsync().GetAwaiter().GetResult();
base.OnFormClosing(e);
}
private void btn_openbrowser_Click(object sender, EventArgs e)
{
try
{
System.Diagnostics.Process.Start("explorer.exe", $"\"http://localhost:{_settings.WebserverPort}\"");
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Fehler beim Öffnen des Browsers");
MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btn_logfolder_Click(object sender, EventArgs e)
{
try
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs");
if (Directory.Exists(logPath))
System.Diagnostics.Process.Start("explorer.exe", logPath);
else
System.Diagnostics.Process.Start("explorer.exe", Environment.CurrentDirectory);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
}
}
private async void syncMarketsaToolStripMenuItem_Click(object sender, EventArgs e)
{
if (_workerRunning)
{
MessageBox.Show("Market sync cannot be started while background workers are running.",
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
btn_syncmarkets.Enabled = false;
Log.Information("Manual market sync triggered...");
// Use a temporary CTS for this operation
using var cts = new CancellationTokenSource();
await _webServer!.RunSingleMarketSyncAsync(cts.Token);
Log.Information("Manual market sync completed successfully.");
MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Manual market sync failed");
MessageBox.Show($"Market sync failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_syncmarkets.Enabled = true;
}
}
}
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>162, 17</value>
</metadata>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>322, 17</value>
</metadata>
</root>
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>Predictalytics.WinFormsHost</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
<ApplicationVisualStyles>true</ApplicationVisualStyles>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
</ItemGroup>
<Target Name="CleanupLocalization" AfterTargets="Build">
<ItemGroup>
<LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" />
</ItemGroup>
<RemoveDir Directories="@(LanguageFolders)" />
</Target>
</Project>
+150
View File
@@ -0,0 +1,150 @@
using Predictalytics.Infrastructure.Logging;
using Serilog;
using Serilog.Events;
namespace Predictalytics.WinFormsHost;
internal static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
var mainForm = new MainForm();
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
// ─── Output template matching terminal format ───
const string textTemplate =
"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
const string simpleTemplate =
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
// ─── Log directory ───
var logBaseDir = Path.Combine(AppContext.BaseDirectory, "logs");
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning)
.Enrich.FromLogContext()
// ── Console (simple) ──
.WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
// ── RichTextBox Terminal ──
.WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning)
// ══════════════════════════════════════════════
// FILE SINKS — By Level
// ══════════════════════════════════════════════
// ALL levels — complete log (daily rotation)
.WriteTo.File(
Path.Combine(logBaseDir, "all", "all-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 30,
fileSizeLimitBytes: 50_000_000,
shared: true)
// INFO only
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Information)
.WriteTo.File(
Path.Combine(logBaseDir, "info", "info-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 14,
shared: true))
// WARNING only
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Warning)
.WriteTo.File(
Path.Combine(logBaseDir, "warning", "warning-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 30,
shared: true))
// ERROR + FATAL
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level >= LogEventLevel.Error)
.WriteTo.File(
Path.Combine(logBaseDir, "error", "error-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 60,
shared: true))
// ══════════════════════════════════════════════
// FILE SINKS — By Platform
// ══════════════════════════════════════════════
// Polymarket
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains("Polymarket"))
.WriteTo.File(
Path.Combine(logBaseDir, "platforms", "polymarket-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 30,
shared: true))
// Limitless
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains("Limitless"))
.WriteTo.File(
Path.Combine(logBaseDir, "platforms", "limitless-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 30,
shared: true))
// Azuro
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains("Azuro"))
.WriteTo.File(
Path.Combine(logBaseDir, "platforms", "azuro-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 30,
shared: true))
// ══════════════════════════════════════════════
// FILE SINK — Worker / Discovery / Scoring
// ══════════════════════════════════════════════
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("SourceContext") &&
e.Properties["SourceContext"].ToString().Contains("Worker"))
.WriteTo.File(
Path.Combine(logBaseDir, "workers", "workers-.log"),
rollingInterval: RollingInterval.Day,
outputTemplate: textTemplate,
retainedFileCountLimit: 14,
shared: true))
.CreateLogger();
Log.Warning("══════════════════════════════════════════════════════");
Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!");
Log.Warning(" 📊 First platform report in 5 minutes.");
Log.Warning("══════════════════════════════════════════════════════");
mainForm.Initialize();
System.Windows.Forms.Application.Run(mainForm);
Log.Information("Application shutting down.");
Log.CloseAndFlush();
}
}
@@ -0,0 +1,12 @@
{
"profiles": {
"Predictalytics.WinFormsHost": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62271;http://localhost:62272"
}
}
}
@@ -0,0 +1,232 @@
using Microsoft.Extensions.Hosting;
using Predictalytics.Api.Endpoints;
using Predictalytics.Worker;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Manages the lifecycle of the embedded Kestrel web server and background workers.
/// </summary>
public class EmbeddedWebServer
{
private WebApplication? _app;
private Task? _runTask;
private CancellationTokenSource? _cts;
private readonly object _lock = new();
public string? ConnectionString { get; set; }
public bool DbConnectionDebug { get; set; }
public async Task StartWebServerAsync(int port = 5000)
{
lock (_lock) { if (_app != null) return; }
await Task.Run(async () =>
{
try
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://localhost:{port}");
string? effectiveConnString = ConnectionString;
if (!string.IsNullOrEmpty(effectiveConnString) && (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
{
throw new InvalidOperationException("Connection string contains an empty 'Database' value.");
}
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);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
builder.Host.UseSerilog();
var app = builder.Build();
app.UseCors();
app.UseSwagger();
app.UseSwaggerUI();
var wwwrootPath = FindWwwrootPath();
if (wwwrootPath != null)
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath)
});
app.MapGet("/", () => Results.File(
Path.Combine(wwwrootPath, "index.html"), "text/html"));
}
app.MapDashboardEndpoints();
app.MapTraderEndpoints();
app.MapMarketEndpoints();
app.MapAlertEndpoints();
app.MapSearchEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
lock (_lock) { _app = app; }
_cts = new CancellationTokenSource();
_runTask = app.RunAsync(_cts.Token);
Log.Information("Kestrel webserver started on port {Port}", port);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to start embedded web server");
throw;
}
});
}
public async Task StopWebServerAsync()
{
WebApplication? app;
lock (_lock) { app = _app; _app = null; }
if (app != null)
{
_cts?.Cancel();
try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
catch (OperationCanceledException) { }
await (app as IAsyncDisposable).DisposeAsync();
Log.Information("Kestrel webserver stopped");
}
}
public async Task StartWorkersAsync(CancellationToken ct)
{
Log.Information("Starting workers (no web server)...");
var host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((ctx, services) =>
{
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);
services.AddWorkerServices();
})
.Build();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, DbConnectionDebug);
await host.RunAsync(ct);
}
/// <summary>
/// Performs a one-time full market sync for all supported platforms.
/// </summary>
public async Task RunSingleMarketSyncAsync(CancellationToken ct)
{
Log.Information("🏛️ Starting manual full market sync...");
var host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((ctx, services) =>
{
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
})
.Build();
var rateLimiter = host.Services.GetRequiredService<IRateLimiter>();
var platformProviders = host.Services.GetRequiredService<IEnumerable<IPlatformProvider>>();
foreach (var p in platformProviders.Where(x => x.IsImplemented))
{
try
{
foreach (var includeClosed in new[] { true, false })
{
Log.Warning("[{Platform}] Starting manual market sync (includeClosed={Closed})...", p.PlatformName, includeClosed);
int totalSynced = 0;
int offset = 0;
const int batchSize = 1000;
while (!ct.IsCancellationRequested)
{
try
{
// Create a fresh scope per batch to keep the DbContext change tracker small
using var scope = host.Services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
// We need to get the provider from the scope to ensure its dependencies (like DbContext) are correct if injected
var provider = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>()
.First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, ct);
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, ct);
if (markets == null || markets.Count == 0)
{
Log.Warning("[{Platform}] No more markets found at offset {Offset} (includeClosed={Closed}). Ending pass.", provider.PlatformName, offset, includeClosed);
break;
}
await marketRepo.AddOrUpdateRangeAsync(markets, ct);
totalSynced += markets.Count;
offset += batchSize;
if (totalSynced % 500 == 0 || markets.Count < batchSize)
Log.Information("[{Platform}] Synced {Total} markets so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed);
if (markets.Count < batchSize)
{
Log.Warning("[{Platform}] Batch was smaller than limit ({Count}/{Limit}), assuming end of list.", provider.PlatformName, markets.Count, batchSize);
break;
}
}
catch (Exception ex)
{
Log.Error(ex, "Error processing batch at offset {Offset} for {Platform}", offset, p.PlatformName);
offset += batchSize;
if (offset > 50000) break; // Safety break
}
}
Log.Warning("✅ [{Platform}] Sync pass complete (includeClosed={Closed}). {Synced} markets synced.", p.PlatformName, includeClosed, totalSynced);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error during manual sync for {Platform}", p.PlatformName);
}
}
}
private static string? FindWwwrootPath()
{
var baseDir = AppContext.BaseDirectory;
// Try to find the src root by traversing up from the build output
// Typical: src/Predictalytics.WinFormsHost/bin/Debug/net8.0-windows/
var candidates = new[]
{
Path.Combine(baseDir, "wwwroot"),
// From bin/Debug/net8.0-windows/ up to src/, then into Api/wwwroot
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")),
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot")),
// Absolute fallback for this specific workspace
@"j:\Softwareprojekte\Predictalytics\Predictalytics\src\Predictalytics.Api\wwwroot"
};
foreach (var path in candidates)
{
var fullPath = Path.GetFullPath(path);
if (Directory.Exists(fullPath))
{
Log.Information("Found wwwroot at: {Path}", fullPath);
return fullPath;
}
}
Log.Warning("wwwroot directory not found! Searched: {Paths}", string.Join(", ", candidates));
return null;
}
}
@@ -0,0 +1,52 @@
using Serilog.Events;
namespace Predictalytics.WinFormsHost;
/// <summary>
/// Creates a thread-safe write action for the RichTextBox terminal.
/// </summary>
public static class TerminalHelper
{
public static Action<string, LogEventLevel> CreateWriteAction(RichTextBox rtb, Control owner)
{
int lineCount = 0;
const int maxLines = 500;
return (message, level) =>
{
if (owner.IsDisposed || rtb.IsDisposed) return;
try
{
owner.BeginInvoke(() =>
{
if (rtb.IsDisposed) return;
lineCount++;
if (lineCount > maxLines)
{
rtb.Clear();
lineCount = 0;
rtb.AppendText("[Terminal cleared — log continues]\n");
}
var color = level switch
{
LogEventLevel.Error or LogEventLevel.Fatal => System.Drawing.Color.FromArgb(255, 82, 82),
LogEventLevel.Warning => System.Drawing.Color.FromArgb(255, 193, 7),
LogEventLevel.Debug => System.Drawing.Color.FromArgb(158, 158, 158),
_ => System.Drawing.Color.FromArgb(76, 175, 80)
};
rtb.SelectionStart = rtb.TextLength;
rtb.SelectionLength = 0;
rtb.SelectionColor = color;
rtb.AppendText(message);
rtb.SelectionColor = rtb.ForeColor;
rtb.ScrollToCaret();
});
}
catch { /* UI thread shutting down */ }
};
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Predictalytics.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:asm.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
@@ -0,0 +1,15 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
},
"WebServer": {
"Port": 5000
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
@@ -0,0 +1,22 @@
using Predictalytics.Worker.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Predictalytics.Worker;
public static class DependencyInjection
{
public static IServiceCollection AddWorkerServices(this IServiceCollection services)
{
services.AddHostedService<MarketSyncWorker>();
services.AddHostedService<PollingWorker>();
services.AddHostedService<TradeHistoryWorker>();
services.AddHostedService<TradeReconciliationWorker>();
services.AddHostedService<DiscoveryWorker>();
services.AddHostedService<TopHolderDiscoveryWorker>();
services.AddHostedService<MarketHistoryWorker>();
services.AddHostedService<ReportingWorker>();
services.AddHostedService<TraderCleanupWorker>();
services.AddHostedService<TraderAnalyticsWorker>();
return services;
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Worker</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically discovers new notable traders on platforms.
/// </summary>
public class DiscoveryWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<DiscoveryWorker> _logger;
public DiscoveryWorker(IServiceProvider services, ILogger<DiscoveryWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🔍 DiscoveryWorker started");
await Task.Delay(10000, stoppingToken); // Delay to let other services initialize
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var discovery = scope.ServiceProvider.GetRequiredService<IDiscoveryService>();
// Run discovery for all implemented platforms
foreach (var platform in new[] { PlatformType.Polymarket, PlatformType.Limitless })
{
try
{
if (stoppingToken.IsCancellationRequested) break;
_logger.LogWarning("[{Platform}] Starting discovery scan...", platform);
var discovered = await discovery.RunDiscoveryAsync(platform, stoppingToken);
_statsService.TrackTraderDiscovery(platform, discovered.Count);
_logger.LogWarning("[{Platform}] Discovery found {Count} traders", platform, discovered.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in discovery for platform {Platform}", platform);
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "DiscoveryWorker error"); }
// Run discovery every 5 minutes
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
_logger.LogInformation("🔍 DiscoveryWorker stopped");
}
}
@@ -0,0 +1,128 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically fetches recent trades for active markets
/// to discover new unknown traders. Updates markets starting with the oldest refreshed.
/// </summary>
public class MarketHistoryWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<MarketHistoryWorker> _logger;
private const int CooldownHours = 6;
private const int MarketsPerCycle = 10;
private const int TradesPerFetch = 100;
public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger)
{ _services = services; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🕵️ MarketHistoryWorker started (cooldown: {Hours}h)", CooldownHours);
await Task.Delay(25000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var markets = await marketRepo.GetMarketsDueForTradeUpdateAsync(CooldownHours, MarketsPerCycle, stoppingToken);
if (markets.Count == 0)
{
_logger.LogDebug("🕵️ No markets due for trade history update. Sleeping.");
}
else
{
_logger.LogInformation("🕵️ Processing {Count} markets for trade history sync", markets.Count);
}
int totalDiscovered = 0;
foreach (var market in markets)
{
if (stoppingToken.IsCancellationRequested) break;
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
var trades = await provider.GetMarketTradesAsync(market.PlatformMarketId, TradesPerFetch, stoppingToken);
// Extract unique wallet addresses from TransientWallet [NotMapped].
// Providers set this during in-memory mapping; it is NOT stored in the DB.
var wallets = trades
.Select(t => t.TransientWallet)
.Where(w => !string.IsNullOrWhiteSpace(w))
.Select(w => w!)
.Distinct()
.ToList();
foreach (var wallet in wallets)
{
var existing = await traderRepo.GetByPlatformIdAsync(
market.Platform, wallet, stoppingToken);
if (existing == null)
{
var trader = new Domain.Entities.Trader
{
Platform = market.Platform,
PlatformUserId = wallet,
DisplayName = wallet.Length > 8 ? wallet[..8] + "..." : wallet, // Will be updated later
IsAutoDiscovered = true,
CreatedAt = DateTime.UtcNow
};
await traderRepo.AddAsync(trader, stoppingToken);
totalDiscovered++;
_logger.LogInformation("[{Platform}] Discovered trader ({Wallet}) from market trades: {Market}",
provider.PlatformName, wallet[..10] + "...",
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
}
}
// Update timestamps
market.LastTradesUpdatedAt = DateTime.UtcNow;
await marketRepo.UpdateAsync(market, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing trade history for market {Market} on {Platform}",
market.PlatformMarketId, market.Platform);
}
}
if (totalDiscovered > 0)
{
_logger.LogInformation("✅ Market history cycle complete. {Count} new traders discovered.", totalDiscovered);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "MarketHistoryWorker error"); }
// Run every 2 minutes
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
_logger.LogInformation("🕵️ MarketHistoryWorker stopped");
}
}
@@ -0,0 +1,111 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically syncs market master data (including outcomes/token IDs)
/// from the Gamma API. This builds the lookup table needed to resolve trades to markets.
/// </summary>
public class MarketSyncWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<MarketSyncWorker> _logger;
public MarketSyncWorker(IServiceProvider services, ILogger<MarketSyncWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🏛️ MarketSyncWorker started");
await Task.Delay(5000, stoppingToken); // Let DB initialize
while (!stoppingToken.IsCancellationRequested)
{
try
{
var rateLimiter = _services.GetRequiredService<IRateLimiter>();
var platformProviders = _services.GetRequiredService<IEnumerable<IPlatformProvider>>();
foreach (var p in platformProviders.Where(x => x.IsImplemented))
{
try
{
if (stoppingToken.IsCancellationRequested) break;
using var platformCtx = PlatformLogContext.Push(p.PlatformName);
int cycleTotalSynced = 0;
foreach (var includeClosed in new[] { false, true })
{
_logger.LogWarning("[{Platform}] Syncing markets (includeClosed={Closed})...", p.PlatformName, includeClosed);
int passSynced = 0;
int offset = 0;
const int batchSize = 1000;
while (!stoppingToken.IsCancellationRequested)
{
// Fresh scope per batch
using var scope = _services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var provider = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>()
.First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, stoppingToken);
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken);
if (markets.Count == 0) break;
await marketRepo.AddOrUpdateRangeAsync(markets, stoppingToken);
passSynced += markets.Count;
cycleTotalSynced += markets.Count;
_statsService.TrackMarketSync(provider.Platform, markets.Count);
offset += batchSize;
if (passSynced % 500 == 0)
_logger.LogWarning("[{Platform}] Synced {Total} markets so far (includeClosed={Closed})...", p.PlatformName, passSynced, includeClosed);
}
}
// Need a temporary scope for stats
using (var scope = _services.CreateScope())
{
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var dbCount = await marketRepo.GetCountAsync(stoppingToken);
_logger.LogWarning("✅ [{Platform}] Market sync complete. {Synced} synced this cycle, {Total} total in DB",
p.PlatformName, cycleTotalSynced, dbCount);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing markets for platform {Platform}", p.PlatformName);
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
{
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
{
_logger.LogWarning("⚠️ Database connection lost in MarketSyncWorker. Retrying in 30m. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "MarketSyncWorker error"); }
// Run every 30 minutes
_logger.LogInformation("🏛️ Next market sync in 30 minutes.");
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
}
_logger.LogInformation("🏛️ MarketSyncWorker stopped");
}
}
@@ -0,0 +1,183 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically polls tracked traders for new trades.
/// Properly deduplicates trades using the Platform+PlatformTradeId unique index.
/// Resolves trades to MarketOutcomes via AssetId → TokenId mapping.
/// </summary>
public class PollingWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<PollingWorker> _logger;
public PollingWorker(IServiceProvider services, ILogger<PollingWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogWarning("📡 PollingWorker started");
await Task.Delay(3000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess;
using (var scope = _services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
tradersToProcess = await repo.GetAllAsync(take: 100, ct: stoppingToken);
}
_logger.LogWarning("📊 Polling {Count} traders...", tradersToProcess.Count);
foreach (var t in tradersToProcess)
{
if (stoppingToken.IsCancellationRequested) break;
using var scope = _services.CreateScope();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var trader = await traderRepo.GetByIdAsync(t.Id, stoppingToken);
if (trader == null) continue;
var provider = providers.FirstOrDefault(p => p.Platform == trader.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(trader.Platform, stoppingToken);
var trades = await provider.GetTraderTradesAsync(trader.PlatformUserId, 100, stoppingToken);
// ── Filter: skip trades with empty PlatformTradeId ──
var validTrades = trades
.Where(t => !string.IsNullOrWhiteSpace(t.PlatformTradeId))
.ToList();
if (validTrades.Count < trades.Count)
{
_logger.LogWarning("{Trader}: Skipped {Count} trades with empty PlatformTradeId",
trader.DisplayName, trades.Count - validTrades.Count);
}
// ── Deduplicate: check each trade against DB ──
var newTrades = new List<Domain.Entities.Trade>();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, stoppingToken);
foreach (var trade in validTrades)
{
if (!knownTradeIds.Contains(trade.PlatformTradeId))
{
trade.TraderId = trader.Id;
// Resolve MarketOutcome via AssetId → TokenId
if (!string.IsNullOrEmpty(trade.AssetId))
{
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, stoppingToken);
// If not found locally, fetch the market from provider
if (outcome == null && !string.IsNullOrEmpty(trade.MarketId))
{
var newMarket = await provider.GetMarketAsync(trade.MarketId, stoppingToken);
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, stoppingToken);
outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, stoppingToken);
}
}
if (outcome != null)
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
// Attempt to resolve DbMarketId via outcome's parent market
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
}
newTrades.Add(trade);
}
}
// ── Persist new trades ──
if (newTrades.Count > 0)
{
// Final deduplication of the batch itself
var uniqueNewTrades = newTrades
.GroupBy(tr => tr.PlatformTradeId)
.Select(g => g.First())
.ToList();
try
{
await tradeRepo.AddRangeAsync(uniqueNewTrades, stoppingToken);
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades (of {Total} fetched)",
trader.DisplayName, uniqueNewTrades.Count, validTrades.Count);
}
catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false))
{
_logger.LogWarning("{Trader}: Skipping batch due to duplicate entries (likely already imported)", trader.DisplayName);
}
}
else
{
_logger.LogDebug("{Trader}: No new trades (all {Count} already known)",
trader.DisplayName, validTrades.Count);
}
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error polling trader {Trader} on {Platform}",
trader.DisplayName, trader.Platform);
}
}
// Recalculate scores and evaluate alerts
using (var scope = _services.CreateScope())
{
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
await scoringService.RecalculateAllScoresAsync(stoppingToken);
await alertService.EvaluateAlertsAsync(stoppingToken);
}
_logger.LogWarning("✅ Polling cycle complete. Next in 60s.");
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
{
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
{
_logger.LogWarning("⚠️ Database connection lost in PollingWorker. Retrying in 60s. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "PollingWorker error"); }
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
}
_logger.LogInformation("📡 PollingWorker stopped");
}
}
@@ -0,0 +1,61 @@
using Predictalytics.Application.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background worker that reports platform statistics every 5 minutes.
/// </summary>
public class ReportingWorker : BackgroundService
{
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<ReportingWorker> _logger;
private const int IntervalMinutes = 5;
public ReportingWorker(IPlatformStatisticsService statsService, ILogger<ReportingWorker> logger)
{
_statsService = statsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Initial delay to align with the first 5-minute mark
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
ReportStats();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ReportingWorker cycle");
}
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
}
}
private void ReportStats()
{
var statsMap = _statsService.GetAndResetStats();
_logger.LogWarning("--- 📊 Platform Activity Report (Last {Min}m) ---", IntervalMinutes);
if (statsMap.Count == 0)
{
_logger.LogWarning("No activity detected across platforms.");
}
foreach (var (platform, stats) in statsMap)
{
_logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}",
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed);
}
_logger.LogWarning("------------------------------------------------");
}
}
@@ -0,0 +1,105 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that discovers new traders by scanning the top holders
/// of active markets. Complements the leaderboard-based discovery by finding
/// traders who hold significant positions in currently traded markets.
/// </summary>
public class TopHolderDiscoveryWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TopHolderDiscoveryWorker> _logger;
public TopHolderDiscoveryWorker(IServiceProvider services, ILogger<TopHolderDiscoveryWorker> logger)
{ _services = services; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("👥 TopHolderDiscoveryWorker started");
await Task.Delay(20000, stoppingToken); // Let market sync populate markets first
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
// Get top active markets by volume
var activeMarkets = await marketRepo.GetActiveAsync(20, stoppingToken);
_logger.LogInformation("👥 Scanning top holders across {Count} active markets", activeMarkets.Count);
int totalDiscovered = 0;
foreach (var market in activeMarkets)
{
if (stoppingToken.IsCancellationRequested) break;
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
var holders = await provider.GetTopHoldersAsync(market.PlatformMarketId, 10, stoppingToken);
foreach (var holder in holders)
{
// Skip empty wallet addresses
if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue;
var existing = await traderRepo.GetByPlatformIdAsync(
market.Platform, holder.PlatformUserId, stoppingToken);
if (existing == null)
{
var trader = new Domain.Entities.Trader
{
Platform = market.Platform,
PlatformUserId = holder.PlatformUserId,
DisplayName = holder.DisplayName,
IsAutoDiscovered = true,
CreatedAt = DateTime.UtcNow
};
await traderRepo.AddAsync(trader, stoppingToken);
totalDiscovered++;
_logger.LogInformation("[{Platform}] Discovered trader {Name} ({Wallet}) from market: {Market}",
provider.PlatformName, holder.DisplayName, holder.PlatformUserId[..10] + "...",
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scanning holders for market {Market}", market.PlatformMarketId);
}
}
_logger.LogInformation("✅ TopHolderDiscovery complete: {Count} new traders discovered", totalDiscovered);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "TopHolderDiscoveryWorker error"); }
// Run every 15 minutes
_logger.LogInformation("👥 Next top holder scan in 15 minutes.");
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
}
_logger.LogInformation("👥 TopHolderDiscoveryWorker stopped");
}
}
@@ -0,0 +1,190 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that loads and updates trade history for all tracked traders.
/// Respects a 6-hour cooldown per trader to avoid excessive API calls.
/// Links trades to MarketOutcomes via AssetId → TokenId mapping.
/// </summary>
public class TradeHistoryWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<TradeHistoryWorker> _logger;
private const int CooldownHours = 12;
private const int TradesPerFetch = 1000;
private const int TradersPerCycle = 100;
public TradeHistoryWorker(IServiceProvider services, ILogger<TradeHistoryWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("📜 TradeHistoryWorker started (update cooldown: {Hours}h)", CooldownHours);
await Task.Delay(15000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess;
using (var scope = _services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
tradersToProcess = await repo.GetTradersDueForTradeUpdateAsync(CooldownHours, TradersPerCycle, stoppingToken);
}
if (tradersToProcess.Count == 0)
{
_logger.LogDebug("📜 No traders due for sync. Sleeping.");
}
else
{
_logger.LogInformation("📜 Processing {Count} traders for trade sync (Initial/Update)", tradersToProcess.Count);
}
var outcomeCache = new System.Collections.Concurrent.ConcurrentDictionary<string, Domain.Entities.MarketOutcome>();
var marketFetchCache = new System.Collections.Concurrent.ConcurrentDictionary<string, Task<Domain.Entities.Market?>>();
await Parallel.ForEachAsync(tradersToProcess, new ParallelOptions
{
MaxDegreeOfParallelism = 5,
CancellationToken = stoppingToken
}, async (t, ct) =>
{
try
{
using var scope = _services.CreateScope();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var trader = await traderRepo.GetByIdAsync(t.Id, ct);
if (trader == null) return;
var provider = providers.FirstOrDefault(p => p.Platform == trader.Platform && p.IsImplemented);
if (provider == null) return;
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(trader.Platform, ct);
bool isInitial = !trader.IsInitialImportComplete;
_logger.LogInformation("{Trader}: Starting {Type} sync", trader.DisplayName, isInitial ? "INITIAL FULL" : "INCREMENTAL");
var fetchedTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, TradesPerFetch, ct);
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, ct);
// Collect all unique AssetIds we might need to resolve
var assetIdsToResolve = validTrades
.Where(tr => !knownTradeIds.Contains(tr.PlatformTradeId) || isInitial)
.Select(tr => tr.AssetId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
// Pre-fill local cache with bulk query
var missingAssetIds = assetIdsToResolve.Where(id => !outcomeCache.ContainsKey(id!)).ToList();
if (missingAssetIds.Count > 0)
{
var resolvedOutcomes = await marketRepo.GetOutcomesByTokenIdsAsync(missingAssetIds!, ct);
foreach (var o in resolvedOutcomes)
{
outcomeCache.TryAdd(o.TokenId, o);
}
}
var newTrades = new List<Domain.Entities.Trade>();
foreach (var trade in validTrades)
{
if (knownTradeIds.Contains(trade.PlatformTradeId))
{
if (!isInitial) break;
continue;
}
trade.TraderId = trader.Id;
if (!string.IsNullOrEmpty(trade.AssetId))
{
if (outcomeCache.TryGetValue(trade.AssetId, out var outcome))
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
else if (!string.IsNullOrEmpty(trade.MarketId))
{
// Fallback for missing outcomes: try to fetch market
var marketTask = marketFetchCache.GetOrAdd(trade.MarketId, _ => provider.GetMarketAsync(trade.MarketId, ct));
var newMarket = await marketTask;
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, ct);
var newOutcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
if (newOutcome != null)
{
outcomeCache.TryAdd(trade.AssetId, newOutcome);
trade.MarketOutcomeId = newOutcome.Id;
trade.Outcome = newOutcome.Label;
if (newOutcome.Market != null)
trade.DbMarketId = newOutcome.Market.Id;
}
}
}
}
newTrades.Add(trade);
}
if (newTrades.Count > 0)
{
var uniqueNewTrades = newTrades.GroupBy(tr => tr.PlatformTradeId).Select(g => g.First()).ToList();
try {
await tradeRepo.AddRangeAsync(uniqueNewTrades, ct);
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count);
} catch (Exception ex) when (ex.ToString().Contains("Duplicate entry")) {
_logger.LogWarning("{Trader}: Skipping batch due to duplicates", trader.DisplayName);
}
}
trader.IsInitialImportComplete = true;
trader.LastTradesUpdatedAt = DateTime.UtcNow;
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { /* App shutting down */ }
catch (Exception ex) { _logger.LogError(ex, "Error syncing trader {TraderId}", t.Id); }
});
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
{
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
{
_logger.LogWarning("⚠️ Database connection lost in TradeHistoryWorker. Retrying in 2m. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "TradeHistoryWorker error"); }
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
_logger.LogInformation("📜 TradeHistoryWorker stopped");
}
}
@@ -0,0 +1,97 @@
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that retroactively resolves missing MarketOutcomeIds for trades.
/// Scans for trades with NULL MarketOutcomeId and attempts to link them via AssetId.
/// </summary>
public class TradeReconciliationWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TradeReconciliationWorker> _logger;
private const int BatchSize = 250;
private const int IntervalMinutes = 15;
public TradeReconciliationWorker(IServiceProvider services, ILogger<TradeReconciliationWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🛠️ TradeReconciliationWorker started (batch: {Batch}, interval: {Min}m)", BatchSize, IntervalMinutes);
// Initial delay to let other workers settle
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
int reconciledCount = await ReconcileBatchAsync(stoppingToken);
if (reconciledCount > 0)
{
_logger.LogInformation("✅ Reconciled {Count} orphaned trades", reconciledCount);
}
else
{
_logger.LogDebug("🛠️ No orphaned trades found for reconciliation");
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Error in TradeReconciliationWorker cycle");
}
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
}
_logger.LogInformation("🛠️ TradeReconciliationWorker stopped");
}
private async Task<int> ReconcileBatchAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var orphanedTrades = await tradeRepo.GetOrphanedTradesAsync(BatchSize, ct);
if (orphanedTrades.Count == 0) return 0;
int count = 0;
foreach (var trade in orphanedTrades)
{
if (ct.IsCancellationRequested) break;
try
{
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
if (outcome != null)
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
await tradeRepo.UpdateAsync(trade, ct);
count++;
}
}
catch (Exception ex)
{
_logger.LogWarning("Failed to reconcile trade {TradeId}: {Msg}", trade.Id, ex.Message);
}
}
return count;
}
}
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Domain.Entities;
namespace Predictalytics.Worker.Services;
public class TraderAnalyticsWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TraderAnalyticsWorker> _logger;
public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("TraderAnalyticsWorker starting...");
while (!ct.IsCancellationRequested)
{
try
{
await RunAnalyticsAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in TraderAnalyticsWorker");
}
_logger.LogInformation("TraderAnalyticsWorker sleeping for 12 hours...");
await Task.Delay(TimeSpan.FromHours(12), ct);
}
}
private async Task RunAnalyticsAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff30d = DateTime.UtcNow.AddDays(-30);
// Find traders active in the last 30 days
var traderIds = await db.Trades
.Where(t => t.ExecutedAt >= cutoff30d)
.Select(t => t.TraderId)
.Distinct()
.ToListAsync(ct);
_logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count);
foreach (var id in traderIds)
{
await UpdateTraderAnalyticsAsync(db, id, ct);
}
await db.SaveChangesAsync(ct);
_logger.LogInformation("Trader analytics update complete.");
}
private async Task UpdateTraderAnalyticsAsync(AppDbContext db, int traderId, CancellationToken ct)
{
var trades = await db.Trades.Where(t => t.TraderId == traderId).ToListAsync(ct);
if (!trades.Any()) return;
var analytics = await db.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == traderId, ct);
if (analytics == null)
{
analytics = new TraderAnalytics { TraderId = traderId };
db.TraderAnalytics.Add(analytics);
}
analytics.LastCalculatedAt = DateTime.UtcNow;
// Simplified PnL calculation: Sum of Sells - Sum of Buys
// This is not perfect but a good starting point as requested.
// In a real scenario, we'd account for current market value of holdings.
analytics.OverallPnL = CalculatePnL(trades, null);
analytics.OverallWinRate = CalculateWinRate(trades, null);
analytics.PnL30d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-30));
analytics.WinRate30d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-30));
analytics.PnL7d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-7));
analytics.WinRate7d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-7));
analytics.PnL24h = CalculatePnL(trades, DateTime.UtcNow.AddHours(-24));
analytics.WinRate24h = CalculateWinRate(trades, DateTime.UtcNow.AddHours(-24));
// Update the trader record too for easy sorting
var trader = await db.Traders.FindAsync(new object[] { traderId }, ct);
if (trader != null)
{
trader.TotalPnl = analytics.OverallPnL;
trader.WinRate = analytics.OverallWinRate;
}
}
private decimal CalculatePnL(List<Trade> trades, DateTime? since)
{
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value) : trades;
// Very simplified: Sells - Buys
// Note: Real PnL should consider if the market resolved in their favor.
// For now, we use the raw trade amounts.
decimal pnl = 0;
foreach (var t in filtered)
{
if (t.Side == Predictalytics.Domain.Enums.TradeSide.Buy) pnl -= t.Amount;
else pnl += t.Amount;
}
return pnl;
}
private decimal CalculateWinRate(List<Trade> trades, DateTime? since)
{
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value).ToList() : trades;
if (!filtered.Any()) return 0;
// Simplified: A "win" is a Sell at a higher price than the average Buy price?
// Actually, without proper position tracking, this is hard.
// Let's assume a "win" is any trade that closed a position in profit.
// For now, let's just return a placeholder or implement a basic logic.
// Since we don't have resolution data easily linked here, we'll return 0 or a dummy.
// Wait, if MarketOutcome is resolved and they held that outcome, it's a win.
// Let's just use 0 for now to avoid misleading data, or
// if we have MarketOutcomeId and it's resolved, we can check.
return 0; // Placeholder until more complex logic is added
}
}

Some files were not shown because too many files have changed in this diff Show More