From 8b9b993d1dfcbb114f20ad07d636aa7a519d9767 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 6 Aug 2026 22:58:10 +0200 Subject: [PATCH] L1b/2: Kultur und Dateisystem plattformunabhaengig machen Kultur - Ausgaben und Parsen haengen nicht mehr am Host: - PdfExporter formatierte Betraege mit ToString("N2") ohne Formatanbieter, also CurrentCulture. Auf dem deutschen Desktop "1.234,56", in einem Container mit LANG=C "1,234.56" - dieselbe Zahl, fuer einen Leser eine andere. Fuer ein ausdruecklich pruefbares Dokument jetzt fest de-DE. - CapitolTradesScraper.ParseDate nutzte DateOnly.TryParse ohne Formatanbieter. Das ist nicht theoretisch: gemessen wurde aus "2026-08-04" unter th-TH das Jahr 1483 (buddhistischer Kalender), unter fa-IR das Jahr 2647 (persischer Kalender), unter ar-SA schlug das Parsen ganz fehl. de-DE und en-US kommen mit ISO klar - genau deshalb faellt so etwas auf dem Entwicklungsrechner nie auf. Jetzt TryParseExact mit InvariantCulture; ein Formatwechsel der Quelle faellt damit auf, statt still ein falsches Datum zu erzeugen. Regressionstest ueber vier Kulturen. - IBKRGatewayService baute den Query-Parameter mit .ToString().ToLower() (Tuerkisch-I) - jetzt fest "true"/"false". PDF-Schriften: PDFsharp 6 loest auf Nicht-Windows-Plattformen nichts von selbst auf, "Segoe UI" gibt es dort nicht - der Export waere zur Laufzeit gescheitert. Neuer DocumentFontResolver: unter Windows bleibt die Plattform zustaendig (unveraenderte Optik), auf Linux wird eine freie Systemschrift gesucht (DejaVu/Liberation/Noto/FreeSans). Bewusst keine Schrift im Repo - das erspart eine Lizenzfrage; fehlt sie, nennt die Fehlermeldung das zu installierende Paket. BackupWorker: - Suchte "mysqldump.exe" in C:\Program Files\... und splittete PATH mit ';'. Auf Linux ist das Trennzeichen ':' - der gesamte PATH waere als ein Eintrag gelesen worden. Jetzt Path.PathSeparator, plattformabhaengige Suchpfade und zusaetzlich "mariadb-dump" (MariaDB hat mysqldump ab 10.5 umbenannt). - Das DB-Passwort stand als Kommandozeilenargument im Prozessbaum. Unter Linux ist /proc//cmdline fuer jeden lokalen Nutzer lesbar - das waere eine neue Offenlegung gewesen, die es unter Windows so nicht gab. Jetzt ueber MYSQL_PWD, nur an den Kindprozess vererbt. Argumente einzeln statt als Zeichenkette (kein Quoting-Problem bei Pfaden mit Leerzeichen). Verifiziert: 188 Tests gruen (+5), Build 0 Fehler/0 Warnungen, Core + 3 Module + Tests bauen fuer linux-x64, --smoke-ui konstruiert alle 7 Fenster. Offen aus L1b und nach L2 verschoben: IAppPaths (Logs/Backups/settings.json/ master.key liegen neben der Binaerdatei; unter /opt ist das nicht schreibbar). Gehoert zum Daemon, wo die Pfade tatsaechlich gebraucht werden. Co-Authored-By: Claude Opus 5 --- .../IBKR/IBKRGatewayService.cs | 2 +- .../Workers/BuiltIn/BackupWorker.cs | 102 ++++++++++----- .../Logic/DocumentFontResolver.cs | 116 ++++++++++++++++++ .../Logic/PdfExporter.cs | 26 +++- .../Scraper/CapitolTradesScraper.cs | 12 +- .../Modules/CapitolTradesScraperTests.cs | 36 ++++++ 6 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 src/IBKRTrader.Modules.Accounting/Logic/DocumentFontResolver.cs diff --git a/src/IBKRTrader.Core/IBKR/IBKRGatewayService.cs b/src/IBKRTrader.Core/IBKR/IBKRGatewayService.cs index af5e406..3c4bcb4 100644 --- a/src/IBKRTrader.Core/IBKR/IBKRGatewayService.cs +++ b/src/IBKRTrader.Core/IBKR/IBKRGatewayService.cs @@ -244,7 +244,7 @@ public class IBKRGatewayService var url = $"iserver/marketdata/history?conid={conid}" + $"&period={Uri.EscapeDataString(period)}" + $"&bar={Uri.EscapeDataString(bar)}" + - $"&outsideRth={outsideRth.ToString().ToLower()}"; + $"&outsideRth={(outsideRth ? "true" : "false")}"; var response = await _http.GetAsync(url, ct); diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs index 5dcfca3..5c2fb4c 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs @@ -1,3 +1,4 @@ +using System.Globalization; using IBKRTrader.Core.Time; using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; @@ -8,9 +9,9 @@ namespace IBKRTrader.Core.Workers.BuiltIn; /// /// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar). -/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\ -/// 2. Kopiert Logs\ → Backups\Logs\ -/// Falls mysqldump nicht gefunden: Warnung und Überspringen. +/// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/ +/// 2. Kopiert Logs/ → Backups/Logs/ +/// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler. /// public class BackupWorker : WorkerBase { @@ -53,39 +54,50 @@ public class BackupWorker : WorkerBase private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct) { - var dump = FindMysqldump(); + var dump = FindDumpTool(); if (dump == null) { - Logger.Warn(Module, "mysqldump.exe nicht gefunden – DB-Backup übersprungen."); + Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden – DB-Backup übersprungen."); return; } var db = _settings.Settings.Database; var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql"); - var args = $"--host={db.Host} --port={db.Port} " + - $"--user={db.User} --password={db.Password} " + - $"--single-transaction --routines --triggers " + - $"{db.Database} --result-file=\"{dumpFile}\""; - var psi = new System.Diagnostics.ProcessStartInfo { FileName = dump, - Arguments = args, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; + // Argumente einzeln statt als eine Zeichenkette: das erspart das Quoting von Pfaden mit + // Leerzeichen und ist auf beiden Plattformen dasselbe. + psi.ArgumentList.Add($"--host={db.Host}"); + psi.ArgumentList.Add($"--port={db.Port.ToString(CultureInfo.InvariantCulture)}"); + psi.ArgumentList.Add($"--user={db.User}"); + psi.ArgumentList.Add("--single-transaction"); + psi.ArgumentList.Add("--routines"); + psi.ArgumentList.Add("--triggers"); + psi.ArgumentList.Add(db.Database); + psi.ArgumentList.Add($"--result-file={dumpFile}"); + + // Passwort über die Umgebung statt über die Kommandozeile. Auf Linux ist + // /proc//cmdline für JEDEN lokalen Nutzer lesbar – das Passwort hätte dort für die + // Dauer des Dumps offen im Prozessbaum gestanden. MYSQL_PWD ist der von MySQL/MariaDB + // dafür vorgesehene Weg und wird nur an diesen Kindprozess vererbt. + psi.Environment["MYSQL_PWD"] = db.Password; + using var proc = System.Diagnostics.Process.Start(psi) - ?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden."); + ?? throw new InvalidOperationException($"{Path.GetFileName(dump)} konnte nicht gestartet werden."); await proc.WaitForExitAsync(ct); if (proc.ExitCode != 0) { var err = await proc.StandardError.ReadToEndAsync(ct); - Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}"); + Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}"); } else { @@ -93,27 +105,57 @@ public class BackupWorker : WorkerBase } } - private static string? FindMysqldump() + /// + /// Namen des Dump-Werkzeugs. MariaDB hat mysqldump ab 10.5 in mariadb-dump + /// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist + /// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows. + /// + private static string[] ToolNames() => + OperatingSystem.IsWindows() + ? ["mariadb-dump.exe", "mysqldump.exe"] + : ["mariadb-dump", "mysqldump"]; + + /// + /// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform, + /// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler). + /// + private static string? FindDumpTool() { - // Häufige Installationspfade auf Windows-Server - var candidates = new[] - { - "mysqldump.exe", - @"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe", - @"C:\Program Files\MySQL\MySQL Server 8.4\bin\mysqldump.exe", - @"C:\xampp\mysql\bin\mysqldump.exe" - }; + var names = ToolNames(); - foreach (var c in candidates) - if (File.Exists(c)) return c; + // Übliche Installationspfade – plattformabhängig. + string[] dirs = OperatingSystem.IsWindows() + ? + [ + @"C:\Program Files\MariaDB\bin", + @"C:\Program Files\MySQL\MySQL Server 8.0\bin", + @"C:\Program Files\MySQL\MySQL Server 8.4\bin", + @"C:\xampp\mysql\bin", + ] + : + [ + "/usr/bin", + "/usr/local/bin", + "/opt/mariadb/bin", + "/opt/homebrew/bin", + ]; - // PATH-Suche + foreach (var name in names) + foreach (var dir in dirs) + { + var full = Path.Combine(dir, name); + if (File.Exists(full)) return full; + } + + // PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux – + // ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen. var pathVar = Environment.GetEnvironmentVariable("PATH") ?? ""; - foreach (var dir in pathVar.Split(';')) - { - var full = Path.Combine(dir.Trim(), "mysqldump.exe"); - if (File.Exists(full)) return full; - } + foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + foreach (var name in names) + { + var full = Path.Combine(dir.Trim(), name); + if (File.Exists(full)) return full; + } return null; } diff --git a/src/IBKRTrader.Modules.Accounting/Logic/DocumentFontResolver.cs b/src/IBKRTrader.Modules.Accounting/Logic/DocumentFontResolver.cs new file mode 100644 index 0000000..031a75c --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Logic/DocumentFontResolver.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using PdfSharp.Fonts; + +namespace IBKRTrader.Modules.Accounting.Logic; + +/// +/// Schriftauflösung für den PDF-Export. +/// +/// Warum das nötig ist: Der Export gab MigraDoc bisher „Segoe UI" vor – eine +/// Windows-Schrift. PDFsharp 6 löst Schriften auf Nicht-Windows-Plattformen nicht von selbst auf; +/// ohne einen eigenen scheitert der Export auf Linux zur Laufzeit. +/// +/// Vorgehen: Unter Windows übernimmt weiterhin die Plattform (unveränderte Optik) – +/// wir geben null zurück und setzen . +/// Auf Linux wird eine der üblichen freien Schriften aus dem System genommen. Bewusst keine +/// Schrift im Repository: das erspart uns eine Lizenzfrage und hält das Paket klein – die +/// Abhängigkeit ist dafür ausdrücklich dokumentiert und die Fehlermeldung nennt das Paket. +/// +public sealed class DocumentFontResolver : IFontResolver +{ + /// Logischer Familienname, den der Export verwendet. + public const string FamilyName = "IBKRTrader Sans"; + + /// Windows-Entsprechung – hält die Optik der bisherigen Exporte. + private const string WindowsFamily = "Segoe UI"; + + /// + /// Freie Sans-Serif-Schriften in Vorzugsreihenfolge, je (regular, bold). DejaVu ist auf + /// praktisch jeder Distribution verfügbar, Liberation und Noto sind die üblichen Alternativen. + /// + private static readonly (string Regular, string Bold)[] Candidates = + [ + ("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"), + ("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf"), + ("NotoSans-Regular.ttf", "NotoSans-Bold.ttf"), + ("FreeSans.ttf", "FreeSansBold.ttf"), + ]; + + private static readonly string[] SearchDirs = + [ + "/usr/share/fonts", + "/usr/local/share/fonts", + "/run/host/usr/share/fonts", // Flatpak/Toolbox + ]; + + private static readonly ConcurrentDictionary Cache = new(); + private static readonly Lazy<(string Regular, string Bold)?> Found = new(Locate); + + /// + /// Richtet die Schriftauflösung einmalig ein. Mehrfachaufrufe sind unschädlich – + /// PDFsharp lässt den Resolver nur einmal setzen. + /// + public static void EnsureConfigured() + { + if (GlobalFontSettings.FontResolver is not null) return; + + GlobalFontSettings.UseWindowsFontsUnderWindows = true; + GlobalFontSettings.FontResolver = new DocumentFontResolver(); + } + + public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic) + { + // Unter Windows die Plattform machen lassen: null bedeutet „nicht zuständig". + if (OperatingSystem.IsWindows()) return null; + + if (Found.Value is not { } files) + throw new InvalidOperationException( + "Für den PDF-Export wurde keine Schriftart gefunden. Auf dem System muss eine freie " + + "Sans-Serif-Schrift installiert sein – unter Debian/Ubuntu z. B. per " + + "\"apt-get install fonts-dejavu-core\", unter Alpine \"apk add font-dejavu\". " + + $"Gesucht wurde in: {string.Join(", ", SearchDirs)}."); + + // Kursiv wird von PDFsharp simuliert; wir liefern nur normal und fett. + var path = bold ? files.Bold : files.Regular; + return new FontResolverInfo(path, mustSimulateBold: false, mustSimulateItalic: italic); + } + + public byte[]? GetFont(string faceName) => + Cache.GetOrAdd(faceName, File.ReadAllBytes); + + /// Sucht das erste vollständige Paar (regular + bold) in den üblichen Verzeichnissen. + private static (string Regular, string Bold)? Locate() + { + var existing = SearchDirs.Where(Directory.Exists).ToArray(); + if (existing.Length == 0) return null; + + foreach (var (regular, bold) in Candidates) + { + var regularPath = FindFile(existing, regular); + var boldPath = FindFile(existing, bold); + + // Ohne Fettschnitt bleibt der Bericht lesbar – dann eben beides normal. + if (regularPath is not null) return (regularPath, boldPath ?? regularPath); + } + + return null; + } + + private static string? FindFile(string[] dirs, string fileName) + { + foreach (var dir in dirs) + { + try + { + var hit = Directory.EnumerateFiles(dir, fileName, SearchOption.AllDirectories).FirstOrDefault(); + if (hit is not null) return hit; + } + catch (UnauthorizedAccessException) { /* Verzeichnis nicht lesbar – nächstes */ } + catch (DirectoryNotFoundException) { /* zwischenzeitlich weg – nächstes */ } + } + return null; + } + + /// Der Familienname, den das Dokument setzen soll. + public static string DocumentFamily => OperatingSystem.IsWindows() ? WindowsFamily : FamilyName; +} diff --git a/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs index 039284d..48ce37a 100644 --- a/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs +++ b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using IBKRTrader.Core.Time; using System.Security.Cryptography; using System.Text; @@ -16,6 +17,17 @@ namespace IBKRTrader.Modules.Accounting.Logic; /// public static class PdfExporter { + /// + /// Zahlenformat des Dokuments – fest deutsch, nicht die Kultur des Hosts. + /// + /// Vorher stand hier ToString("N2") ohne Formatanbieter, also CurrentCulture. Auf + /// dem deutschen Windows-Desktop kam "1.234,56" heraus; in einem Linux-Container mit + /// LANG=C oder InvariantGlobalization wäre daraus "1,234.56" geworden – dieselbe + /// Zahl, für einen Leser aber eine andere. Für ein Dokument, das ausdrücklich als prüfbare + /// Aufstellung gedacht ist, darf das Format nicht vom Rechner abhängen, auf dem es entsteht. + /// + private static readonly CultureInfo DocumentCulture = CultureInfo.GetCultureInfo("de-DE"); + public static byte[] Render( PeriodStatement statement, IReadOnlyList monthly, @@ -23,12 +35,16 @@ public static class PdfExporter string currencyCode, decimal currencyFactor, string currencyNote) { decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero); - string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode; + string M(decimal baseAmount) => V(baseAmount).ToString("N2", DocumentCulture) + " " + currencyCode; + + // Schriftauflösung: auf Linux gibt es kein "Segoe UI", und PDFsharp löst dort nichts von + // selbst auf – ohne das scheitert der Export zur Laufzeit. Siehe DocumentFontResolver. + DocumentFontResolver.EnsureConfigured(); var doc = new Document(); doc.Info.Title = "Buchhalterische Abrechnung"; var style = doc.Styles["Normal"]!; - style.Font.Name = "Segoe UI"; + style.Font.Name = DocumentFontResolver.DocumentFamily; style.Font.Size = 9; var section = doc.AddSection(); @@ -64,8 +80,8 @@ public static class PdfExporter AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld)); AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult)); AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance)); - AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString()); - AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString()); + AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString(DocumentCulture)); + AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString(DocumentCulture)); // ---- Monatsvergleich ---- if (monthly.Count > 1) @@ -84,7 +100,7 @@ public static class PdfExporter HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion"); foreach (var e in periodEntries.OrderBy(e => e.Timestamp)) DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side, - Trim(e.Symbol, 12), e.Quantity.ToString("0.###"), e.PriceNative.ToString("0.###"), + Trim(e.Symbol, 12), e.Quantity.ToString("0.###", DocumentCulture), e.PriceNative.ToString("0.###", DocumentCulture), M(e.NetBase), Trim(e.TransactionId, 22)); // ---- Methodik / Nachweis ---- diff --git a/src/IBKRTrader.Modules.CongressTrading/Scraper/CapitolTradesScraper.cs b/src/IBKRTrader.Modules.CongressTrading/Scraper/CapitolTradesScraper.cs index 6ac06b4..6b02b79 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Scraper/CapitolTradesScraper.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Scraper/CapitolTradesScraper.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -121,7 +122,7 @@ public class CapitolTradesScraper if (match.Success) { - var total = int.Parse(match.Groups[1].Value); + var total = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); _logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden."); return total; } @@ -326,11 +327,18 @@ public class CapitolTradesScraper return null; } + /// + /// Datum aus dem RSC-Stream ("2026-08-04T…"). Bewusst TryParseExact mit fester + /// Invariant-Kultur: TryParse ohne Formatanbieter hätte die Kultur des Rechners benutzt + /// und damit je nach Host anders geraten. Ändert die Quelle ihr Format, soll das auffallen + /// (null → Satz wird verworfen) statt still zu einem falschen Datum zu werden. + /// private static DateOnly? ParseDate(string? s) { if (string.IsNullOrEmpty(s)) return null; var datePart = s.Length > 10 ? s[..10] : s; - return DateOnly.TryParse(datePart, out var d) ? d : null; + return DateOnly.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var d) ? d : null; } private static string CapFirst(string s) diff --git a/tests/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs b/tests/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs index 2997fb7..1983a9a 100644 --- a/tests/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs +++ b/tests/IBKRTrader.Tests/Modules/CapitolTradesScraperTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using FluentAssertions; using IBKRTrader.Core.Logging; using IBKRTrader.Modules.CongressTrading.Scraper; @@ -44,6 +45,41 @@ public class CapitolTradesScraperTests } } + [Theory] + [InlineData("de-DE")] // Tag.Monat.Jahr + [InlineData("th-TH")] // buddhistischer Kalender + [InlineData("fa-IR")] // persischer Kalender + [InlineData("ar-SA")] // Hidschri-Kalender + [InlineData("")] // Invariant – so verhält sich ein Container ohne LANG + public void ParseTradesFromHtml_LiefertDieselbenDaten_UnabhaengigVonDerHostKultur(string culture) + { + // Die Datumsfelder kamen ueber DateOnly.TryParse OHNE Formatanbieter herein, also mit der + // Kultur des Rechners. Bei Kulturen mit eigenem Kalender ist das nicht theoretisch: + // aus "2026-08-04" wurde unter th-TH das Jahr 1483 und unter fa-IR das Jahr 2647, + // unter ar-SA schlug das Parsen ganz fehl. de-DE und en-US kommen mit ISO klar - genau + // deshalb faellt so ein Fehler auf dem Entwicklungsrechner nie auf. + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + + var (trades, _) = new CapitolTradesScraper(new LoggingService()).ParseTradesFromHtml(LoadFixture()); + + trades.Should().NotBeEmpty(); + trades.Should().Contain(t => t.TradeDate != null, + "die Fixture enthaelt Handelsdaten im Format yyyy-MM-dd"); + + // Alle geparsten Daten muessen plausibel sein – ein kulturbedingter Tag/Monat-Dreher + // faellt hier auf, weil Tage > 12 sonst gar nicht parsen wuerden. + foreach (var t in trades.Where(t => t.TradeDate != null)) + t.TradeDate!.Value.Year.Should().BeInRange(2000, 2100); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + [Fact] public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades() {