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()
{