Fix: Implement FixPlan part A9, A10, B
This commit is contained in:
@@ -52,4 +52,14 @@ public class JobRepository : IJobRepository
|
||||
_db.BackgroundJobs.Update(job);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task ResetHungJobsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddMinutes(-15);
|
||||
await _db.BackgroundJobs
|
||||
.Where(j => j.Status == JobStatus.InProgress && j.StartedAt < cutoff)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(j => j.Status, JobStatus.Pending)
|
||||
.SetProperty(j => j.StartedAt, (DateTime?)null), ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public class TradeRepository : ITradeRepository
|
||||
t.TransactionHash = StringHelper.Truncate(t.TransactionHash, 66);
|
||||
}
|
||||
|
||||
foreach (var chunk in tradeList.Chunk(1000))
|
||||
foreach (var chunk in tradeList.Chunk(500))
|
||||
{
|
||||
var sb = new System.Text.StringBuilder("INSERT IGNORE INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched) VALUES ");
|
||||
var parameters = new List<object>();
|
||||
@@ -99,8 +99,37 @@ public class TradeRepository : ITradeRepository
|
||||
parameters.Add(t.IsContextEnriched);
|
||||
}
|
||||
|
||||
var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct);
|
||||
_logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted);
|
||||
int maxRetries = 3;
|
||||
var backoffs = new[] { 250, 500, 1000 };
|
||||
for (int retry = 0; retry <= maxRetries; retry++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct);
|
||||
_logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var mysqlEx = ex as MySqlConnector.MySqlException ?? ex.InnerException as MySqlConnector.MySqlException;
|
||||
if (mysqlEx != null && (mysqlEx.Number == 1213 || mysqlEx.Number == 1205))
|
||||
{
|
||||
if (retry == maxRetries)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to insert {Count} trades after {Retries} retries due to deadlocks.", chunk.Length, maxRetries);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Deadlock detected during trade insertion. Retrying in {Delay}ms... (Attempt {Attempt}/{Max})", backoffs[retry], retry + 1, maxRetries);
|
||||
await Task.Delay(backoffs[retry], ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ public class AzuroProvider : IPlatformProvider
|
||||
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<Trade>> GetTradesPagedAsync(string platformUserId, int limitPerRequest = 500, 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>()); }
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ public class LimitlessProvider : IPlatformProvider
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Trade>> GetTradesPagedAsync(string platformUserId, int limitPerRequest = 500, CancellationToken ct = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
|
||||
@@ -46,6 +46,39 @@ public class PolymarketApiClient
|
||||
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<PolymarketTradeResponse>> GetTradesPagedAsync(string walletAddress, int limit = 500, CancellationToken ct = default)
|
||||
{
|
||||
var allTrades = new List<PolymarketTradeResponse>();
|
||||
long? endTimestamp = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = $"/activity?user={walletAddress}&limit={limit}";
|
||||
if (endTimestamp.HasValue)
|
||||
{
|
||||
url += $"&end={endTimestamp.Value}";
|
||||
}
|
||||
|
||||
var batch = await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct);
|
||||
if (batch == null || batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
allTrades.AddRange(batch);
|
||||
|
||||
if (batch.Count < limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var oldestTimestamp = batch.Min(t => t.Timestamp);
|
||||
endTimestamp = oldestTimestamp - 1;
|
||||
}
|
||||
|
||||
return allTrades;
|
||||
}
|
||||
|
||||
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, int offset = 0, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"/trades?condition_id={conditionId}&limit={limit}&offset={offset}";
|
||||
|
||||
@@ -59,10 +59,20 @@ public class PolymarketTradeResponse
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Size { get; set; }
|
||||
|
||||
[JsonPropertyName("usdcSize")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double UsdcSize { get; set; }
|
||||
|
||||
[JsonPropertyName("price")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Price { get; set; }
|
||||
|
||||
[JsonPropertyName("outcomeIndex")]
|
||||
public int? OutcomeIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("pseudonym")] public string? Pseudonym { get; set; }
|
||||
|
||||
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
@@ -137,7 +147,7 @@ public class GammaMarketResponse
|
||||
[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; }
|
||||
[JsonPropertyName("umaResolutionStatus")] public string? UmaResolutionStatus { get; set; }
|
||||
[JsonPropertyName("negRisk")] public bool NegRisk { get; set; }
|
||||
[JsonPropertyName("closedTime")] public string? ClosedTime { get; set; }
|
||||
[JsonPropertyName("takerFee")] [JsonConverter(typeof(FlexibleDoubleConverter))] public double TakerFee { get; set; }
|
||||
|
||||
@@ -56,13 +56,50 @@ public class PolymarketProvider : IPlatformProvider
|
||||
TransactionHash = r.TransactionHash?.ToLowerInvariant(),
|
||||
TraderId = 0,
|
||||
TransientWallet = wallet,
|
||||
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetTradesPagedAsync(string platformUserId, int limitPerRequest = 500, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
_logger.LogDebug("Fetching all paged trades for {Wallet}", platformUserId);
|
||||
var raw = await _api.GetTradesPagedAsync(platformUserId, limitPerRequest, ct);
|
||||
_logger.LogInformation("Fetched {Count} trades total for {Wallet}", raw.Count, platformUserId);
|
||||
|
||||
var mappedTrades = raw.Select(r =>
|
||||
{
|
||||
var wallet = !string.IsNullOrEmpty(r.User) ? r.User :
|
||||
!string.IsNullOrEmpty(r.ProxyWallet) ? r.ProxyWallet :
|
||||
platformUserId;
|
||||
var side = MapTradeSide(r);
|
||||
var sideStr = side.ToString().ToUpperInvariant();
|
||||
return new Trade
|
||||
{
|
||||
Platform = PlatformType.Polymarket,
|
||||
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
|
||||
? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
|
||||
: $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{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?.ToLowerInvariant(),
|
||||
TraderId = 0,
|
||||
TransientWallet = wallet,
|
||||
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).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, 0, ct);
|
||||
@@ -90,6 +127,7 @@ public class PolymarketProvider : IPlatformProvider
|
||||
TransactionHash = r.TransactionHash,
|
||||
TraderId = 0,
|
||||
TransientWallet = wallet,
|
||||
TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -106,7 +144,8 @@ public class PolymarketProvider : IPlatformProvider
|
||||
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
|
||||
(decimal)r.CurrentValue, (decimal)r.PercentPnl,
|
||||
r.AssetId
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
@@ -300,8 +339,6 @@ public class PolymarketProvider : IPlatformProvider
|
||||
EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
|
||||
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
|
||||
DbCreatedAt = DateTime.UtcNow,
|
||||
IsResolved = raw.Resolved || raw.Closed,
|
||||
ResolutionOutcome = raw.ResolutionOutcome,
|
||||
LastUpdatedAt = DateTime.UtcNow,
|
||||
FeeRateBps = (decimal)(raw.TakerFee * 10000),
|
||||
IsNegRisk = raw.NegRisk,
|
||||
@@ -313,6 +350,11 @@ public class PolymarketProvider : IPlatformProvider
|
||||
var outcomePrices = ParseJsonStringArray(raw.OutcomePrices);
|
||||
var tokenIds = ParseJsonStringArray(raw.ClobTokenIds);
|
||||
|
||||
bool pricesSnapped = false;
|
||||
bool hasHighPrice = false;
|
||||
bool allSnapped = outcomePrices.Count > 0;
|
||||
string? snappedWinnerLabel = null;
|
||||
|
||||
for (int i = 0; i < outcomeLabels.Count; i++)
|
||||
{
|
||||
decimal price = 0;
|
||||
@@ -320,8 +362,6 @@ public class PolymarketProvider : IPlatformProvider
|
||||
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))
|
||||
@@ -329,6 +369,21 @@ public class PolymarketProvider : IPlatformProvider
|
||||
label = $"{raw.GroupItemTitle} - {label}";
|
||||
}
|
||||
|
||||
if (price <= 0.02m || price >= 0.98m)
|
||||
{
|
||||
if (price >= 0.98m)
|
||||
{
|
||||
hasHighPrice = true;
|
||||
snappedWinnerLabel = label;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
allSnapped = false;
|
||||
}
|
||||
|
||||
string tokenId = i < tokenIds.Count ? tokenIds[i] : "";
|
||||
|
||||
market.Outcomes.Add(new MarketOutcome
|
||||
{
|
||||
Label = label,
|
||||
@@ -338,6 +393,11 @@ public class PolymarketProvider : IPlatformProvider
|
||||
});
|
||||
}
|
||||
|
||||
pricesSnapped = allSnapped && hasHighPrice;
|
||||
|
||||
market.IsResolved = raw.UmaResolutionStatus == "resolved" || (raw.Closed && pricesSnapped);
|
||||
market.ResolutionOutcome = market.IsResolved ? snappedWinnerLabel : null;
|
||||
|
||||
return market;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,16 +177,13 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
break;
|
||||
|
||||
case TradeSide.Redeem:
|
||||
var market = trade.MarketOutcome.Market;
|
||||
var isResolved = market?.IsResolved ?? false;
|
||||
var resolutionOutcome = market?.ResolutionOutcome;
|
||||
var isWinner = isResolved && Predictalytics.Domain.Helpers.MarketOutcomeHelper.IsWinningOutcome(trade.MarketOutcome, resolutionOutcome);
|
||||
|
||||
var payout = isWinner ? 1.00m : 0.00m;
|
||||
currentBalance += (pos.SharesHeld * payout);
|
||||
pos.RealizedPnl += pos.SharesHeld * (payout - pos.AvgCost);
|
||||
pos.SharesHeld = 0;
|
||||
pos.AvgCost = 0;
|
||||
if (TryDeterminePayout(trade.MarketOutcome, out var redeemPayout))
|
||||
{
|
||||
currentBalance += (pos.SharesHeld * redeemPayout);
|
||||
pos.RealizedPnl += pos.SharesHeld * (redeemPayout - pos.AvgCost);
|
||||
pos.SharesHeld = 0;
|
||||
pos.AvgCost = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case TradeSide.Split:
|
||||
@@ -241,13 +238,9 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
{
|
||||
if (pos.SharesHeld > 0 && pos.MarketOutcome?.Market != null)
|
||||
{
|
||||
var market = pos.MarketOutcome.Market;
|
||||
if (market.IsResolved)
|
||||
if (TryDeterminePayout(pos.MarketOutcome, out var virtualPayout))
|
||||
{
|
||||
var isWinner = Predictalytics.Domain.Helpers.MarketOutcomeHelper.IsWinningOutcome(pos.MarketOutcome, market.ResolutionOutcome);
|
||||
var payout = isWinner ? 1.00m : 0.00m;
|
||||
|
||||
var virtualPnlDelta = pos.SharesHeld * (payout - pos.AvgCost);
|
||||
var virtualPnlDelta = pos.SharesHeld * (virtualPayout - pos.AvgCost);
|
||||
pos.RealizedPnl += virtualPnlDelta;
|
||||
pos.SharesHeld = 0;
|
||||
pos.AvgCost = 0;
|
||||
@@ -553,4 +546,25 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool TryDeterminePayout(MarketOutcome outcome, out decimal payout)
|
||||
{
|
||||
payout = 0m;
|
||||
var market = outcome.Market;
|
||||
if (market == null || !market.IsResolved) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(market.ResolutionOutcome))
|
||||
{
|
||||
payout = Predictalytics.Domain.Helpers.MarketOutcomeHelper.IsWinningOutcome(outcome, market.ResolutionOutcome) ? 1.00m : 0.00m;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (market.Outcomes != null && market.Outcomes.Any(o => o.CurrentPrice >= 0.98m))
|
||||
{
|
||||
payout = outcome.CurrentPrice >= 0.98m ? 1.00m : 0.00m;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user