Phase 0: UTC-Datumsauswertung korrigiert, Portierungsanalyse und -plan

Vorarbeiten fuer die Linux-/NET-10-Portierung (siehe docs/PLAN-Linux-Portierung.md).

DateTime.TryParse ohne DateTimeStyles liefert bei Z-Zeitstempeln Kind=Local und
rechnet in Lokalzeit um. An 13 Stellen in PolymarketProvider und LimitlessProvider
wurden so ISO-Zeitstempel der Plattform-APIs eingelesen, waehrend der Fallback
derselben Zuweisungen (DateTime.UtcNow) korrektes UTC schrieb - die Spalten
enthielten also gemischt verschobene und korrekte Werte. Auf einem Server mit
TZ=UTC haette derselbe Code andere Werte erzeugt als unter Windows.

Neuer Helfer Infrastructure/Helpers/DateParsing.cs kapselt das Parsen mit
InvariantCulture und AdjustToUniversal|AssumeUniversal; alle Fundstellen umgestellt.

Ausserdem:
- LimitlessProvider: Volume-Parsing auf InvariantCulture umgestellt
- EmbeddedWebServer: hartkodierten j:\-Absolutpfad als wwwroot-Fallback entfernt

Ein Backfill der Altdaten erfolgt bewusst nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:49:11 +02:00
co-authored by Claude Opus 5
parent aa19a89301
commit f82a693818
6 changed files with 896 additions and 17 deletions
@@ -0,0 +1,34 @@
using System.Globalization;
namespace Predictalytics.Infrastructure.Helpers;
/// <summary>
/// Parses timestamp strings from platform APIs as UTC.
/// <para>
/// The platforms deliver ISO-8601 strings; the domain stores UTC throughout — every call
/// site falls back to <see cref="DateTime.UtcNow"/>, and the neighbouring DbCreatedAt is
/// always UTC. A plain <c>DateTime.TryParse</c> breaks that in two ways: it honours the
/// machine's locale, and it converts a "Z"-suffixed timestamp to <em>local</em> time. The
/// same API payload would therefore land in the database differently on a Berlin
/// workstation than on a UTC server.
/// </para>
/// </summary>
public static class DateParsing
{
private const DateTimeStyles UtcStyles =
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal;
/// <summary>
/// Parses an API timestamp as UTC. Returns null when absent or unparseable.
/// Timestamps without a zone designator are treated as UTC.
/// </summary>
public static DateTime? ParseUtc(string? value) =>
DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var parsed)
? parsed
: null;
/// <summary>
/// Parses an API timestamp as UTC, falling back to the current UTC time.
/// </summary>
public static DateTime ParseUtcOrNow(string? value) => ParseUtc(value) ?? DateTime.UtcNow;
}
@@ -1,6 +1,8 @@
using System.Globalization;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Helpers;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
@@ -235,7 +237,7 @@ public class LimitlessProvider : IPlatformProvider
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,
ExecutedAt = DateParsing.ParseUtcOrNow(e.CreatedAt),
TransactionHash = e.TxHash,
TransientWallet = wallet,
};
@@ -265,11 +267,12 @@ public class LimitlessProvider : IPlatformProvider
Category = catMap.Category,
Subcategory = catMap.Subcategory,
ImageUrl = raw.ImageUrl ?? "",
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""),
NumberStyles.Any, CultureInfo.InvariantCulture, 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),
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
: DateParsing.ParseUtc(raw.ExpirationDate),
IsResolved = raw.Closed,
LastUpdatedAt = DateTime.UtcNow
};
@@ -2,6 +2,7 @@ using System.Text.Json;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Helpers;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
@@ -216,9 +217,9 @@ public class PolymarketProvider : IPlatformProvider
Title = rawEv.Title,
Description = rawEv.Description,
ImageUrl = rawEv.Image,
StartDate = DateTime.TryParse(rawEv.StartDate, out var esd) ? esd : null,
EndDate = DateTime.TryParse(rawEv.EndDate, out var eed) ? eed : null,
CreatedAt = DateTime.TryParse(rawEv.CreatedAt, out var ecd) ? ecd : DateTime.UtcNow,
StartDate = DateParsing.ParseUtc(rawEv.StartDate),
EndDate = DateParsing.ParseUtc(rawEv.EndDate),
CreatedAt = DateParsing.ParseUtcOrNow(rawEv.CreatedAt),
DbCreatedAt = DateTime.UtcNow,
IsActive = rawEv.Active,
IsClosed = rawEv.Closed,
@@ -312,9 +313,9 @@ public class PolymarketProvider : IPlatformProvider
Title = rawEvent.Title,
Description = rawEvent.Description,
ImageUrl = rawEvent.Image,
StartDate = DateTime.TryParse(rawEvent.StartDate, out var sd) ? sd : null,
EndDate = DateTime.TryParse(rawEvent.EndDate, out var ed) ? ed : null,
CreatedAt = DateTime.TryParse(rawEvent.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
StartDate = DateParsing.ParseUtc(rawEvent.StartDate),
EndDate = DateParsing.ParseUtc(rawEvent.EndDate),
CreatedAt = DateParsing.ParseUtcOrNow(rawEvent.CreatedAt),
DbCreatedAt = DateTime.UtcNow,
IsActive = rawEvent.Active,
IsClosed = rawEvent.Closed,
@@ -359,14 +360,14 @@ public class PolymarketProvider : IPlatformProvider
Volume = (decimal)raw.Volume,
Volume24h = (decimal)raw.Volume24hr,
Liquidity = (decimal)raw.Liquidity,
StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null,
EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
StartDate = DateParsing.ParseUtc(raw.StartDate),
EndDate = DateParsing.ParseUtc(raw.EndDate ?? raw.EndDateIso),
CreatedAt = DateParsing.ParseUtcOrNow(raw.CreatedAt),
DbCreatedAt = DateTime.UtcNow,
LastUpdatedAt = DateTime.UtcNow,
FeeRateBps = (decimal)(raw.TakerFee * 10000),
IsNegRisk = raw.NegRisk,
ClosedAt = DateTime.TryParse(raw.ClosedTime, out var mct) ? mct : null
ClosedAt = DateParsing.ParseUtc(raw.ClosedTime)
};
// Parse outcomes, prices, and token IDs from JSON strings
@@ -315,9 +315,7 @@ public class EmbeddedWebServer
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"
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot"))
};
foreach (var path in candidates)