diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html
index 9fb5860..c7d85d7 100644
--- a/src/Predictalytics.Api/wwwroot/index.html
+++ b/src/Predictalytics.Api/wwwroot/index.html
@@ -546,6 +546,7 @@
Win Rate |
PnL |
Volumen |
+ Avg. Return |
Trades |
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js
index b1d184a..16b7501 100644
--- a/src/Predictalytics.Api/wwwroot/js/app.js
+++ b/src/Predictalytics.Api/wwwroot/js/app.js
@@ -658,11 +658,12 @@ async function viewTrader(id) {
${fmt.pct(c.winRate)} |
${fmt.pnl(c.totalPnL)} |
${fmt.usd(c.totalVolume)} |
+ ${(c.avgReturnPct || 0).toFixed(1)}% |
${fmt.num(c.totalTrades)} |
`).join('');
} else {
- catBody.innerHTML = '| No category data available |
';
+ catBody.innerHTML = '| No category data available |
';
}
const tbody = document.getElementById('td-tradesBody');
diff --git a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
index a099585..3b273ee 100644
--- a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
+++ b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
@@ -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().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.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
+ }
+ }
}
diff --git a/src/Predictalytics.Application.Tests/Services/TradeRepositoryTests.cs b/src/Predictalytics.Application.Tests/Services/TradeRepositoryTests.cs
new file mode 100644
index 0000000..853d640
--- /dev/null
+++ b/src/Predictalytics.Application.Tests/Services/TradeRepositoryTests.cs
@@ -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()
+ .UseSqlite(connection)
+ .Options;
+
+ using var db = new AppDbContext(options);
+ db.Database.EnsureCreated();
+
+ var repo = new TradeRepository(db, NullLogger.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(() => 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);
+ }
+}
diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs
index 7a7d580..249ec1b 100644
--- a/src/Predictalytics.Application/DTOs/TraderDto.cs
+++ b/src/Predictalytics.Application/DTOs/TraderDto.cs
@@ -85,7 +85,8 @@ public record TraderCategoryPerformanceDto(
decimal TotalPnL,
int TotalTrades,
int WinningTrades,
- decimal WinRate
+ decimal WinRate,
+ decimal AvgReturnPct
);
public record TraderPositionDto(
diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs
index 87b9fb4..7d6b808 100644
--- a/src/Predictalytics.Application/Services/AnalyticsService.cs
+++ b/src/Predictalytics.Application/Services/AnalyticsService.cs
@@ -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,
diff --git a/src/Predictalytics.Domain/Entities/Trade.cs b/src/Predictalytics.Domain/Entities/Trade.cs
index 2e94fcb..9757d9a 100644
--- a/src/Predictalytics.Domain/Entities/Trade.cs
+++ b/src/Predictalytics.Domain/Entities/Trade.cs
@@ -66,6 +66,13 @@ public class Trade
/// Transaction hash (for blockchain-based platforms).
public string? TransactionHash { get; set; }
+ /// USDC size of the trade (raw currency size traded).
+ [Column(TypeName = "decimal(18,6)")]
+ public decimal? UsdcSize { get; set; }
+
+ /// Outcome index traded.
+ public int? OutcomeIndex { get; set; }
+
// ── Context Enrichment (AI Strategy Detection) ───────────
/// Market price 1 minute before trade execution.
diff --git a/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs b/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs
index e1c0b97..b453e2d 100644
--- a/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs
+++ b/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs
@@ -20,6 +20,9 @@ public class TraderCategoryPerformance
/// Total Profit/Loss in this category (USD).
public decimal TotalPnL { get; set; }
+ /// Total invested amount in this category (USD).
+ public decimal TotalInvested { get; set; }
+
/// Number of trades in this category.
public int TotalTrades { get; set; }
@@ -28,4 +31,7 @@ public class TraderCategoryPerformance
/// Calculated win rate for this category (0.0 - 1.0).
public decimal WinRate => TotalTrades > 0 ? (decimal)WinningTrades / TotalTrades : 0;
+
+ /// Calculated ROI percentage (Total PnL / Total Invested * 100).
+ public decimal AvgReturnPct => TotalInvested > 0 ? (TotalPnL / TotalInvested) * 100m : 0;
}
diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs
index c194ce8..7422d5d 100644
--- a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs
+++ b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs
@@ -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