G3 & G4: Map UsdcSize, OutcomeIndex, compute Category ROI and add migration

This commit is contained in:
Richard
2026-07-19 12:35:03 +02:00
parent 8a39b912a6
commit 6fd7e563f4
14 changed files with 1484 additions and 10 deletions
@@ -546,6 +546,7 @@
<th class="num-col">Win Rate</th>
<th class="num-col">PnL</th>
<th class="num-col">Volumen</th>
<th class="num-col">Avg. Return</th>
<th>Trades</th>
</tr>
</thead>
+2 -1
View File
@@ -658,11 +658,12 @@ async function viewTrader(id) {
<td>${fmt.pct(c.winRate)}</td>
<td>${fmt.pnl(c.totalPnL)}</td>
<td>${fmt.usd(c.totalVolume)}</td>
<td class="${(c.avgReturnPct || 0) >= 0 ? 'pnl-positive' : 'pnl-negative'}">${(c.avgReturnPct || 0).toFixed(1)}%</td>
<td>${fmt.num(c.totalTrades)}</td>
</tr>
`).join('');
} else {
catBody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:16px; color:var(--text-muted)">No category data available</td></tr>';
catBody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:16px; color:var(--text-muted)">No category data available</td></tr>';
}
const tbody = document.getElementById('td-tradesBody');
@@ -881,4 +881,61 @@ public class PositionPnLEngineTests
Assert.Equal(6, analytics.Trades30d);
}
}
[Fact]
public async Task RecalculateTraderPositionsAsync_CalculatesCategoryTotalInvestedAndROI()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var ev = new Event { Id = 2, Platform = PlatformType.Polymarket, Slug = "e2", Title = "E2", Tags = "Crypto" };
db.Set<Event>().Add(ev);
var market = new Market { Id = 20, EventId = 2, PlatformMarketId = 2L, Question = "BTC to 100k?", Category = MarketCategory.Crypto, Subcategory = "Bitcoin", IsResolved = true };
var outcome = new MarketOutcome { Id = 200, MarketId = 20, Label = "Yes", TokenId = "t200", CurrentPrice = 1.00m };
market.Outcomes.Add(outcome);
db.Traders.Add(trader);
db.Markets.Add(market);
// Buy trade (Invested = 100 USD)
db.Trades.Add(new Trade
{
Id = 601, TraderId = 1, DbMarketId = 20, MarketOutcomeId = 200,
Side = TradeSide.Buy, Price = 0.50m, Size = 200m, Amount = 100m,
ExecutedAt = DateTime.UtcNow.AddMinutes(-10),
MarketOutcome = outcome
});
// Sell trade (Payout = 200 USD -> PnL = 100 USD)
db.Trades.Add(new Trade
{
Id = 602, TraderId = 1, DbMarketId = 20, MarketOutcomeId = 200,
Side = TradeSide.Sell, Price = 1.00m, Size = 200m, Amount = 200m,
ExecutedAt = DateTime.UtcNow,
MarketOutcome = outcome
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert
using (var db = CreateDbContext(dbName))
{
var perf = await db.TraderCategoryPerformances.FirstOrDefaultAsync(p => p.TraderId == 1 && p.Category == MarketCategory.Crypto);
Assert.NotNull(perf);
Assert.Equal(100m, perf.TotalInvested);
Assert.Equal(100m, perf.TotalPnL);
Assert.Equal(100m, perf.AvgReturnPct); // (100 / 100) * 100 = 100% ROI
}
}
}
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Data.Repositories;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class TradeRepositoryTests
{
[Fact]
public async Task AddRangeAsync_GeneratesCorrectSqlAndParameters()
{
using var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
using var db = new AppDbContext(options);
db.Database.EnsureCreated();
var repo = new TradeRepository(db, NullLogger<TradeRepository>.Instance);
var trade = new Trade
{
PlatformTradeId = "test_tx_usdc",
MarketId = "cond_1",
AssetId = "asset_1",
Outcome = "Yes",
Side = TradeSide.Buy,
Price = 0.50m,
Size = 100m,
Amount = 50m,
ExecutedAt = DateTime.UtcNow,
UsdcSize = 123.45m,
OutcomeIndex = 1
};
// SQLite will throw due to MySql specific "ON DUPLICATE KEY UPDATE" syntax.
// We catch it and verify that it failed at execution rather than mapping/parameter building.
var exception = await Assert.ThrowsAnyAsync<Exception>(() => repo.AddRangeAsync(new[] { trade }));
// Assert that the exception is a syntax error near "DUPLICATE", which verifies the query was correctly built and sent
Assert.Contains("DUPLICATE", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
@@ -85,7 +85,8 @@ public record TraderCategoryPerformanceDto(
decimal TotalPnL,
int TotalTrades,
int WinningTrades,
decimal WinRate
decimal WinRate,
decimal AvgReturnPct
);
public record TraderPositionDto(
@@ -243,7 +243,8 @@ public class AnalyticsService : IAnalyticsService
p.TotalPnL,
p.TotalTrades,
p.WinningTrades,
p.WinRate)).ToList();
p.WinRate,
p.AvgReturnPct)).ToList();
return new TraderDetailDto(trader.Id, trader.Platform.ToString(), trader.PlatformUserId, trader.DisplayName,
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
@@ -66,6 +66,13 @@ public class Trade
/// <summary>Transaction hash (for blockchain-based platforms).</summary>
public string? TransactionHash { get; set; }
/// <summary>USDC size of the trade (raw currency size traded).</summary>
[Column(TypeName = "decimal(18,6)")]
public decimal? UsdcSize { get; set; }
/// <summary>Outcome index traded.</summary>
public int? OutcomeIndex { get; set; }
// ── Context Enrichment (AI Strategy Detection) ───────────
/// <summary>Market price 1 minute before trade execution.</summary>
@@ -20,6 +20,9 @@ public class TraderCategoryPerformance
/// <summary>Total Profit/Loss in this category (USD).</summary>
public decimal TotalPnL { get; set; }
/// <summary>Total invested amount in this category (USD).</summary>
public decimal TotalInvested { get; set; }
/// <summary>Number of trades in this category.</summary>
public int TotalTrades { get; set; }
@@ -28,4 +31,7 @@ public class TraderCategoryPerformance
/// <summary>Calculated win rate for this category (0.0 - 1.0).</summary>
public decimal WinRate => TotalTrades > 0 ? (decimal)WinningTrades / TotalTrades : 0;
/// <summary>Calculated ROI percentage (Total PnL / Total Invested * 100).</summary>
public decimal AvgReturnPct => TotalInvested > 0 ? (TotalPnL / TotalInvested) * 100m : 0;
}
@@ -70,14 +70,14 @@ public class TradeRepository : ITradeRepository
foreach (var chunk in tradeList.Chunk(500))
{
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount) VALUES ");
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount, UsdcSize, OutcomeIndex) VALUES ");
var parameters = new List<object>();
for (int i = 0; i < chunk.Length; i++)
{
var t = chunk[i];
int pIdx = i * 16;
sb.Append($"({{{pIdx}}}, {{{pIdx + 1}}}, {{{pIdx + 2}}}, {{{pIdx + 3}}}, {{{pIdx + 4}}}, {{{pIdx + 5}}}, {{{pIdx + 6}}}, {{{pIdx + 7}}}, {{{pIdx + 8}}}, {{{pIdx + 9}}}, {{{pIdx + 10}}}, {{{pIdx + 11}}}, {{{pIdx + 12}}}, {{{pIdx + 13}}}, {{{pIdx + 14}}}, {{{pIdx + 15}}})");
int pIdx = i * 18;
sb.Append($"({{{pIdx}}}, {{{pIdx + 1}}}, {{{pIdx + 2}}}, {{{pIdx + 3}}}, {{{pIdx + 4}}}, {{{pIdx + 5}}}, {{{pIdx + 6}}}, {{{pIdx + 7}}}, {{{pIdx + 8}}}, {{{pIdx + 9}}}, {{{pIdx + 10}}}, {{{pIdx + 11}}}, {{{pIdx + 12}}}, {{{pIdx + 13}}}, {{{pIdx + 14}}}, {{{pIdx + 15}}}, {{{pIdx + 16}}}, {{{pIdx + 17}}})");
if (i < chunk.Length - 1)
sb.Append(", ");
@@ -98,10 +98,12 @@ public class TradeRepository : ITradeRepository
parameters.Add((int)t.Platform);
parameters.Add(t.IsContextEnriched);
parameters.Add(t.AggregatedCount ?? (object?)null);
parameters.Add(t.UsdcSize ?? (object?)null);
parameters.Add(t.OutcomeIndex ?? (object?)null);
}
// For Aggregated Trades, we want UPSERT logic to update size, amount and VWAP
sb.Append(" ON DUPLICATE KEY UPDATE Price=VALUES(Price), Size=VALUES(Size), Amount=VALUES(Amount), AggregatedCount=VALUES(AggregatedCount);");
sb.Append(" ON DUPLICATE KEY UPDATE Price=VALUES(Price), Size=VALUES(Size), Amount=VALUES(Amount), AggregatedCount=VALUES(AggregatedCount), UsdcSize=VALUES(UsdcSize), OutcomeIndex=VALUES(OutcomeIndex);");
int maxRetries = 3;
var backoffs = new[] { 250, 500, 1000 };
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTradeUsdcOutcomeIndexAndCategoryTotalInvested : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OutcomeIndex",
table: "Trades",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "UsdcSize",
table: "Trades",
type: "decimal(18,6)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TotalInvested",
table: "TraderCategoryPerformances",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OutcomeIndex",
table: "Trades");
migrationBuilder.DropColumn(
name: "UsdcSize",
table: "Trades");
migrationBuilder.DropColumn(
name: "TotalInvested",
table: "TraderCategoryPerformances");
}
}
}
@@ -453,6 +453,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int?>("OutcomeIndex")
.HasColumnType("int");
b.Property<int>("Platform")
.HasColumnType("int");
@@ -485,6 +488,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<decimal?>("UsdcSize")
.HasColumnType("decimal(18,6)");
b.HasKey("Id");
b.HasIndex("AssetId");
@@ -765,6 +771,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("TotalInvested")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("TotalPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
@@ -56,7 +56,9 @@ public class PolymarketProvider : IPlatformProvider
TransactionHash = r.TransactionHash?.ToLowerInvariant(),
TraderId = 0,
TransientWallet = wallet,
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym,
UsdcSize = (decimal)r.UsdcSize,
OutcomeIndex = r.OutcomeIndex
};
}).ToList();
@@ -94,7 +96,9 @@ public class PolymarketProvider : IPlatformProvider
TransactionHash = r.TransactionHash?.ToLowerInvariant(),
TraderId = 0,
TransientWallet = wallet,
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym,
UsdcSize = (decimal)r.UsdcSize,
OutcomeIndex = r.OutcomeIndex
};
}).ToList();
@@ -127,7 +131,9 @@ public class PolymarketProvider : IPlatformProvider
TransactionHash = r.TransactionHash,
TraderId = 0,
TransientWallet = wallet,
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym,
UsdcSize = (decimal)r.UsdcSize,
OutcomeIndex = r.OutcomeIndex
};
}).ToList();
@@ -398,6 +398,7 @@ public class PositionPnLEngine : IPositionPnLEngine
{
existing.TotalVolume = kvp.Value.TotalVolume;
existing.TotalPnL = kvp.Value.TotalPnL;
existing.TotalInvested = kvp.Value.TotalInvested;
existing.TotalTrades = kvp.Value.TotalTrades;
existing.WinningTrades = kvp.Value.WinningTrades;
_db.TraderCategoryPerformances.Update(existing);
@@ -603,6 +604,11 @@ public class PositionPnLEngine : IPositionPnLEngine
}
}
decimal marketBuyAmount = marketGroup
.Where(t => t.Side == TradeSide.Buy)
.Sum(t => t.Amount);
perf.TotalInvested += marketBuyAmount;
perf.TotalPnL += marketPnl;
perf.TotalTrades += 1;
if (marketPnl > 0)