G3 & G4: Map UsdcSize, OutcomeIndex, compute Category ROI and add migration
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+1275
File diff suppressed because it is too large
Load Diff
+49
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user