diff --git a/src/PolyTrader.Modules.Accounting/AccountingModule.cs b/src/PolyTrader.Modules.Accounting/AccountingModule.cs index 0a86bb2..3669db5 100644 --- a/src/PolyTrader.Modules.Accounting/AccountingModule.cs +++ b/src/PolyTrader.Modules.Accounting/AccountingModule.cs @@ -37,6 +37,13 @@ namespace PolyTrader.Modules.Accounting // A-2: neutrale Periodenabrechnung/BWA + Währungs-View (USDC/USD/EUR). services.AddSingleton(); + // EZB-Tageskurse (#533): Ohne sie ist EUR nicht darstellbar. Der Abruf haengt an einem + // eigenen HttpClient mit knappem Zeitlimit — eine haengende Kursabfrage darf den + // stuendlichen Ingest-Lauf nicht aufhalten. + services.AddSingleton(_ => new EcbFxRateSource( + new System.Net.Http.HttpClient { Timeout = System.TimeSpan.FromSeconds(30) })); + services.AddSingleton(); + // Ingest-Quellen: offline Null-Stubs (das Modul läuft ohne Live-Anbindung und bucht korrekt // nichts). Im Zielland werden die echten Quellen (Polymarket /activity, Alchemy-Transfers, // GetUsdcBalanceAsync) + die System-Contract-Whitelist registriert. Muster wie RF. diff --git a/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs b/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs index 0156c98..187befb 100644 --- a/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs +++ b/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs @@ -39,11 +39,12 @@ namespace PolyTrader.Modules.Accounting.Services private readonly IBalanceAnchorSource _balance; private readonly AccountingSystemContracts _contracts; private readonly TerminalLogger _logger; + private readonly FxRateSyncService _fxSync; public AccountingIngestService( TradingState state, ILedgerRepository ledger, IIngestRunRepository runs, IRawSnapshotRepository raw, IActivitySource activity, ITransferSource transfers, IBalanceAnchorSource balance, - AccountingSystemContracts contracts, TerminalLogger logger) + AccountingSystemContracts contracts, TerminalLogger logger, FxRateSyncService fxSync) { _state = state; _ledger = ledger; @@ -54,6 +55,7 @@ namespace PolyTrader.Modules.Accounting.Services _balance = balance; _contracts = contracts; _logger = logger; + _fxSync = fxSync; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -75,6 +77,11 @@ namespace PolyTrader.Modules.Accounting.Services /// Ein Durchlauf über alle Live-Accounts (mit gesetzter WalletAddress). public async Task IngestAllAsync(bool backfill, CancellationToken ct) { + // Zuerst die EZB-Tageskurse (#533): Sie gelten fuer alle Konten und werden auch dann + // gebraucht, wenn kein einziger Live-Account eingerichtet ist. Ein Fehlschlag beim Abruf + // ist protokolliert und haelt den Ingest nicht auf. + await _fxSync.SyncDailyAsync(force: backfill, ct); + var liveAccounts = _state.Accounts.Values .Where(a => !a.IsDemo && !string.IsNullOrWhiteSpace(a.WalletAddress)) .OrderBy(a => a.AccountId) diff --git a/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs b/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs index 24f0042..f1bf377 100644 --- a/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs +++ b/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs @@ -9,8 +9,23 @@ namespace PolyTrader.Modules.Accounting.Services /// /// Anzeige-Währung einer Abrechnung. USDC/USD sind immer verfügbar (USD über die dokumentierte /// 1:1-Annahme), EUR nur, wenn ein amtlicher Tageskurs geladen ist. + /// + /// ist gesetzt, wenn der verwendete Kurs zwar gültig, aber + /// veraltet ist (#533). Der Wert ist dann weiterhin benutzbar — nur eben mit Vorbehalt, und der + /// gehört auf den Beleg. /// - public sealed record CurrencyContext(string Code, decimal Factor, bool Available, string Note); + public sealed record CurrencyContext( + string Code, + decimal Factor, + bool Available, + string Note, + DateTime? RateDate = null, + int RateAgeDays = 0, + string? Warning = null) + { + /// Der Kurs ist älter als erlaubt — Zahlen stimmen nur noch ungefähr. + public bool IsStale => Warning != null; + } /// /// Report-Service (A-2): baut neutrale Periodenabrechnungen aus dem Ledger und stellt die @@ -25,6 +40,13 @@ namespace PolyTrader.Modules.Accounting.Services /// USDC→USD-Faktor (dokumentierte 1:1-Annahme; Zielland ggf. über Settings umstellbar). public decimal UsdcToUsd { get; set; } = FxConverter.DefaultUsdcToUsd; + /// + /// Ab welchem Alter ein EZB-Kurs als veraltet gilt (#533). Drei Tage, weil genau so groß die + /// planmäßige Lücke über ein Wochenende ist: Der Freitagskurs gilt bis Montag, und das ist + /// keine Störung, sondern der Normalfall. Erst darüber hinaus fehlt tatsächlich etwas. + /// + public const int MaxRateAgeDays = 3; + public AccountingReportService(ILedgerRepository ledger, IFxRateRepository fx) { _ledger = ledger; @@ -45,11 +67,26 @@ namespace PolyTrader.Modules.Accounting.Services case "USD": return new CurrencyContext("USD", UsdcToUsd, true, $"USDC≈USD (1:1-Annahme, Faktor {UsdcToUsd})."); case "EUR": - var rate = _fx != null ? FxConverter.NearestOnOrBefore(_fx.GetAll(), asOf) : null; + List rates; + try { rates = _fx != null ? _fx.GetAll() : new List(); } + catch { rates = new List(); } + + var rate = FxConverter.NearestOnOrBefore(rates, asOf); if (rate == null) - return new CurrencyContext("EUR", 0m, false, "Keine EZB-Tageskurse geladen – EUR nicht verfügbar."); - return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true, - $"USD→EUR {rate.UsdToEur} ({rate.Date:yyyy-MM-dd}, {rate.Source}); USDC≈USD 1:1."); + return new CurrencyContext("EUR", 0m, false, + "Keine EZB-Tageskurse geladen – EUR nicht verfügbar."); + + int ageDays = (int)Math.Round((asOf.Date - rate.Date.Date).TotalDays); + string note = $"USD→EUR {rate.UsdToEur} ({rate.Date:yyyy-MM-dd}, {rate.Source}); USDC≈USD 1:1."; + + // Freitag → Montag sind drei Tage; erst darüber hinaus fehlt wirklich etwas. + string? warning = ageDays > MaxRateAgeDays + ? $"Der verwendete EZB-Kurs ist vom {rate.Date:dd.MM.yyyy} und damit {ageDays} Tage " + + $"alt (Stichtag {asOf.Date:dd.MM.yyyy}). Die EUR-Beträge sind nur Näherungen." + : null; + + return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true, note, + rate.Date.Date, ageDays, warning); default: return new CurrencyContext("USDC", 1.0m, true, "Native Buchungswährung."); } diff --git a/src/PolyTrader.Modules.Accounting/Services/EcbFxSource.cs b/src/PolyTrader.Modules.Accounting/Services/EcbFxSource.cs new file mode 100644 index 0000000..5467d43 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Services/EcbFxSource.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Services +{ + /// + /// Quelle amtlicher USD→EUR-Tageskurse. Hinter einem Interface, damit das Modul offline läuft + /// (Null-Quelle) und der Abruf testbar bleibt — dasselbe Muster wie bei den Ingest-Quellen. + /// + public interface IFxRateSource + { + /// + /// Kurse im Zeitraum [from, to]. Wochenenden und Feiertage fehlen darin — die EZB stellt an + /// diesen Tagen keinen Referenzkurs fest. Leere Liste, wenn nichts abrufbar war. + /// + Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct); + } + + /// Offline-Vorgabe: ruft nichts ab. Das Modul bucht dann korrekt keine Kurse. + public sealed class NullFxRateSource : IFxRateSource + { + public Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) => + Task.FromResult(new List()); + } + + /// + /// Holt die EZB-Referenzkurse über die offizielle Daten-API (SDMX 2.1, CSV-Ausgabe). + /// + /// Die Reihe liefert USD je EUR, nicht EUR je USD. OBS_VALUE = 1,1652 heißt + /// „1 EUR = 1,1652 USD". Gespeichert wird der Kehrwert, weil die + /// Richtung USD→EUR meint — das Ledger führt USDC, gerechnet wird nach EUR. Wer das verwechselt, + /// bekommt eine Abrechnung, die um rund 35 % danebenliegt und trotzdem plausibel aussieht. + /// + /// Doku: https://data.ecb.europa.eu/help/api/data-examples + /// + public sealed class EcbFxRateSource : IFxRateSource + { + /// Tageskurs (D) USD gegen EUR, Referenzkurs (SP00), Durchschnitt (A). + public const string SeriesKey = "EXR/D.USD.EUR.SP00.A"; + + public const string BaseUrl = "https://data-api.ecb.europa.eu/service/data/"; + + public const string SourceName = "ECB"; + + private readonly HttpClient _http; + + public EcbFxRateSource(HttpClient http) + { + _http = http; + } + + public async Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) + { + if (to.Date < from.Date) + return new List(); + + string url = $"{BaseUrl}{SeriesKey}?format=csvdata" + + $"&startPeriod={from:yyyy-MM-dd}&endPeriod={to:yyyy-MM-dd}"; + + using var response = await _http.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + string csv = await response.Content.ReadAsStringAsync(ct); + return ParseCsv(csv); + } + + /// + /// Wandelt die CSV-Antwort in Kurse. Öffentlich und seiteneffektfrei, damit das Format ohne + /// Netzzugriff geprüft werden kann. + /// + public static List ParseCsv(string csv) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(csv)) + return result; + + var lines = csv.Split('\n'); + if (lines.Length < 2) + return result; + + var header = SplitCsvLine(lines[0]); + int idxDate = Array.IndexOf(header, "TIME_PERIOD"); + int idxValue = Array.IndexOf(header, "OBS_VALUE"); + if (idxDate < 0 || idxValue < 0) + return result; + + for (int i = 1; i < lines.Length; i++) + { + if (string.IsNullOrWhiteSpace(lines[i])) continue; + var cells = SplitCsvLine(lines[i]); + if (cells.Length <= Math.Max(idxDate, idxValue)) continue; + + if (!DateTime.TryParseExact(cells[idxDate].Trim(), "yyyy-MM-dd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + continue; + + if (!decimal.TryParse(cells[idxValue].Trim(), NumberStyles.Float, + CultureInfo.InvariantCulture, out var usdPerEur)) + continue; + + // Fehlende Beobachtung (Feiertag) kommt als leerer Wert; 0 wäre eine Division durch null. + if (usdPerEur <= 0m) continue; + + result.Add(new FxRate + { + Date = date.Date, + UsdToEur = Math.Round(1m / usdPerEur, 6, MidpointRounding.AwayFromZero), + Source = SourceName + }); + } + + return result.OrderBy(r => r.Date).ToList(); + } + + /// + /// Zerlegt eine CSV-Zeile und achtet dabei auf Anführungszeichen: Die EZB liefert in + /// TITLE_COMPL einen Text mit Komma darin. + /// + internal static string[] SplitCsvLine(string line) + { + var cells = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + foreach (char c in line.TrimEnd('\r')) + { + if (c == '"') { inQuotes = !inQuotes; continue; } + if (c == ',' && !inQuotes) { cells.Add(current.ToString()); current.Clear(); continue; } + current.Append(c); + } + + cells.Add(current.ToString()); + return cells.ToArray(); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Services/FxRateSyncService.cs b/src/PolyTrader.Modules.Accounting/Services/FxRateSyncService.cs new file mode 100644 index 0000000..bebcaa1 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Services/FxRateSyncService.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Accounting.Models; +using PolyTrader.Modules.Accounting.Persistence; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.Accounting.Services +{ + /// + /// Hält die amtlichen USD→EUR-Tageskurse in acc_fx_rates aktuell (#533). + /// + /// Die Kurse werden gespeichert, nicht bei Bedarf geholt: Eine Abrechnung für einen + /// vergangenen Monat muss den Kurs von damals verwenden und beim nächsten Aufruf dasselbe Ergebnis + /// liefern. Ein Abruf zur Anzeigezeit würde rückwirkend andere Zahlen erzeugen — für einen + /// Buchhaltungsbeleg ist das unbrauchbar. + /// + /// Angestoßen wird der Abgleich beim ersten Ingest-Lauf des Tages + /// (); weitere Läufe desselben Tages tun nichts. + /// + public sealed class FxRateSyncService + { + /// + /// Wie weit beim allerersten Lauf zurück geholt wird, wenn noch kein einziger Kurs vorliegt. + /// Deckt die Historie ab, für die es überhaupt Buchungen geben kann. + /// + public const int InitialBackfillDays = 400; + + /// + /// Überlappung beim laufenden Abgleich: Die EZB stellt den Kurs erst nachmittags fest, und + /// nach einem langen Wochenende fehlen mehrere Tage. Lieber ein paar Tage doppelt abrufen — + /// der Upsert ist idempotent. + /// + public const int IncrementalLookbackDays = 10; + + private readonly IFxRateSource _source; + private readonly IFxRateRepository _repo; + private readonly TerminalLogger _logger; + + /// + /// Der manuelle Anstoss ueber das WebUI und die stuendliche Schleife koennen sich treffen. + /// Ohne Sperre laufen dann zwei Abgleiche gleichzeitig: Beide lesen den Bestand, bevor einer + /// geschrieben hat, beide halten dieselben Kurse fuer neu und beide fragen die EZB. Genau das + /// war im Testlauf zu sehen — „280 Kurse uebernommen" und „53 Kurse uebernommen" in derselben + /// Sekunde. + /// + private readonly SemaphoreSlim _gate = new(1, 1); + + private DateTime _lastSyncedDay = DateTime.MinValue; + + public FxRateSyncService(IFxRateSource source, IFxRateRepository repo, TerminalLogger logger) + { + _source = source; + _repo = repo; + _logger = logger; + } + + /// + /// Gleicht höchstens einmal je Kalendertag ab. übergeht die Sperre + /// (Backfill-Lauf, manueller Anstoß). + /// + public async Task SyncDailyAsync(bool force, CancellationToken ct) + { + DateTime today = DateTime.UtcNow.Date; + if (!force && _lastSyncedDay == today) + return 0; + + await _gate.WaitAsync(ct); + try + { + // Zweite Pruefung innerhalb der Sperre: Wer hier gewartet hat, waehrend ein anderer + // Lauf gerade fertig wurde, hat nichts mehr zu tun. + if (!force && _lastSyncedDay == today) + return 0; + + int written = await SyncAsync(today, ct); + _lastSyncedDay = today; + return written; + } + finally + { + _gate.Release(); + } + } + + /// + /// Der testbare Kern: bestimmt den fehlenden Zeitraum, holt ihn und schreibt ihn fort. + /// Gibt die Zahl der übernommenen Kurse zurück; Fehler beim Abruf brechen den Ingest nicht ab. + /// + public async Task SyncAsync(DateTime today, CancellationToken ct) + { + List existing; + try { existing = _repo.GetAll(); } + catch (Exception ex) + { + _logger.Error($"FX-Abgleich: Kurse nicht lesbar – {ex.Message}"); + return 0; + } + + DateTime from = existing.Count == 0 + ? today.AddDays(-InitialBackfillDays) + : existing.Max(r => r.Date).Date.AddDays(-IncrementalLookbackDays); + + if (from > today) from = today; + + List fetched; + try + { + fetched = await _source.GetRatesAsync(from, today, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Kein Netz, EZB nicht erreichbar: Der Bestand bleibt, wie er ist. Die Abrechnung + // warnt dann von sich aus über das Alter des zuletzt bekannten Kurses. + _logger.Warning($"FX-Abgleich fehlgeschlagen ({ex.Message}). Bestehende Kurse bleiben gültig."); + return 0; + } + + if (fetched.Count == 0) + return 0; + + var known = existing.ToDictionary(r => r.Date.Date, r => r.UsdToEur); + int written = 0; + + foreach (var rate in fetched) + { + if (known.TryGetValue(rate.Date.Date, out var have) && have == rate.UsdToEur) + continue; + + try + { + _repo.Upsert(rate); + written++; + } + catch (Exception ex) + { + _logger.Error($"FX-Abgleich: {rate.Date:yyyy-MM-dd} nicht speicherbar – {ex.Message}"); + } + } + + if (written > 0) + { + var newest = fetched.Max(r => r.Date); + _logger.Info($"💱 [Accounting] {written} EZB-Tageskurse übernommen (bis {newest:yyyy-MM-dd})."); + } + + return written; + } + } +} diff --git a/src/PolyTrader.Web/Endpoints/AccountingEndpoints.cs b/src/PolyTrader.Web/Endpoints/AccountingEndpoints.cs index 51e65e8..74e6756 100644 --- a/src/PolyTrader.Web/Endpoints/AccountingEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/AccountingEndpoints.cs @@ -71,7 +71,11 @@ namespace PolyTrader.Web.Endpoints code = currCtx.Code, factor = currCtx.Factor, available = currCtx.Available, - note = currCtx.Note + note = currCtx.Note, + rateDate = currCtx.RateDate.HasValue ? currCtx.RateDate.Value.ToString("yyyy-MM-dd") : null, + rateAgeDays = currCtx.RateAgeDays, + isStale = currCtx.IsStale, + warning = currCtx.Warning }, statement = new { @@ -114,19 +118,37 @@ namespace PolyTrader.Web.Endpoints string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant(); var currCtx = reports.ResolveCurrency(currCode, toDate); + + // Ohne gueltigen Kurs ist der Umrechnungsfaktor 0 — jeder Betrag im Beleg waere + // dann 0,00, ohne als Fehler erkennbar zu sein. Ein Buchhaltungsbeleg, den man + // weiterreichen kann, darf so nicht entstehen (#533). + if (!currCtx.Available) + { + return Results.BadRequest(new + { + error = $"Waehrung {currCtx.Code} ist nicht verfuegbar: {currCtx.Note} " + + "Es wurde kein Dokument erzeugt.", + currency = currCtx.Code, + note = currCtx.Note + }); + } + var statement = reports.BuildStatement(accId, fromDate, toDate); var monthly = reports.BuildMonthly(accId, fromDate, toDate); var periodEntries = ledgerRepo.Query(accId, fromDate, toDate, 20000); try { + // Ein veralteter Kurs sperrt den Beleg nicht — er gehoert nur sichtbar darauf. + string note = currCtx.IsStale ? currCtx.Note + " ACHTUNG: " + currCtx.Warning : currCtx.Note; + byte[] pdfBytes = PdfExporter.Render( statement, monthly, periodEntries, currCtx.Code, currCtx.Factor, - currCtx.Note); + note); string fileName = $"Abrechnung_{currCtx.Code}_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.pdf"; return Results.File(pdfBytes, "application/pdf", fileName); diff --git a/src/PolyTrader.Web/wwwroot/css/app.css b/src/PolyTrader.Web/wwwroot/css/app.css index dbe68ce..ec91599 100644 --- a/src/PolyTrader.Web/wwwroot/css/app.css +++ b/src/PolyTrader.Web/wwwroot/css/app.css @@ -38,6 +38,7 @@ body { border-bottom: 1px solid var(--border); padding: 0 16px; min-height: 56px; + flex-wrap: wrap; display: flex; align-items: center; justify-content: space-between; @@ -60,14 +61,14 @@ body { .header-nav { display: flex; gap: 2px; - overflow-x: auto; - white-space: nowrap; - scrollbar-width: none; - -ms-overflow-style: none; -} - -.header-nav::-webkit-scrollbar { - display: none; + /* Bei 11 Ansichten passt die Leiste unter ~1620 px nicht mehr in eine Zeile. + Sie bricht deshalb in eine zweite um, statt seitlich wegzuscrollen: ein + Tab, den man nicht sieht, ist ein Tab, den niemand findet (#532). */ + flex: 1 1 auto; + flex-wrap: wrap; + min-width: 0; + row-gap: 4px; + padding: 6px 0; } .nav-link { @@ -1002,3 +1003,21 @@ tr:hover td { + +/* Veralteter EZB-Kurs (#533): Die Zahlen bleiben benutzbar, der Vorbehalt muss aber + am Beleg stehen und nicht nur im Kleingedruckten. */ +.rate-warning { + margin-top: 10px; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--warning); + background: rgba(234, 179, 8, 0.12); + color: var(--warning); + font-size: 12.5px; + line-height: 1.5; +} + +.rate-warning::before { + content: "⚠️"; + margin-right: 6px; +} diff --git a/src/PolyTrader.Web/wwwroot/index.html b/src/PolyTrader.Web/wwwroot/index.html index 750e355..1d1e936 100644 --- a/src/PolyTrader.Web/wwwroot/index.html +++ b/src/PolyTrader.Web/wwwroot/index.html @@ -905,6 +905,7 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d +
diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js index ab96875..df447f0 100644 --- a/src/PolyTrader.Web/wwwroot/js/app.js +++ b/src/PolyTrader.Web/wwwroot/js/app.js @@ -2124,6 +2124,21 @@ async function loadAccountingBwa() { noteEl.textContent = data.currency?.note ? `* ${data.currency.note}` : ''; } + // Veralteter oder fehlender EZB-Kurs (#533) + const warnEl = document.getElementById('accRateWarning'); + if (warnEl) { + const c = data.currency || {}; + if (c.warning) { + warnEl.textContent = c.warning; + warnEl.hidden = false; + } else if (c.available === false) { + warnEl.textContent = `${c.note} Umrechnung und Export in ${c.code} sind deshalb gesperrt.`; + warnEl.hidden = false; + } else { + warnEl.hidden = true; + } + } + // 2. Monthly Table const tbody = document.getElementById('accMonthlyTbody'); if (tbody) { @@ -2158,7 +2173,7 @@ async function loadAccountingBwa() { } catch (e) {} } -function exportAccountingPdf() { +async function exportAccountingPdf() { const accId = document.getElementById('accFilterAccount')?.value || ''; const fromVal = document.getElementById('accFilterFrom')?.value || ''; const toVal = document.getElementById('accFilterTo')?.value || ''; @@ -2168,10 +2183,42 @@ function exportAccountingPdf() { if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`; if (toVal) url += `&to=${encodeURIComponent(toVal)}`; - window.open(url, '_blank'); + // Nicht mit window.open: Der Endpunkt lehnt eine nicht verfügbare Währung mit 400 ab, + // und die nackte JSON-Fehlermeldung in einem neuen Reiter ist keine Antwort (#533). + await downloadExport(url, 'PDF'); } -function exportAccountingCsv() { +/** Holt einen Export, meldet Fehler im Klartext und stößt sonst den Download an. */ +async function downloadExport(url, label) { + try { + const res = await fetch(url); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { msg = (await res.json()).error || msg; } catch (e) { /* kein JSON */ } + alert(`${label}-Export nicht möglich: + +${msg}`); + return; + } + + const blob = await res.blob(); + const name = (res.headers.get('Content-Disposition') || '').match(/filename="?([^";]+)"?/)?.[1] + || `export.${label.toLowerCase()}`; + + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); + } catch (e) { + alert(`${label}-Export fehlgeschlagen: ${e.message}`); + } +} + +async function exportAccountingCsv() { const accId = document.getElementById('accFilterAccount')?.value || ''; const fromVal = document.getElementById('accFilterFrom')?.value || ''; const toVal = document.getElementById('accFilterTo')?.value || ''; @@ -2181,7 +2228,7 @@ function exportAccountingCsv() { if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`; if (toVal) url += `&to=${encodeURIComponent(toVal)}`; - window.open(url, '_blank'); + downloadExport(url, 'CSV'); } async function loadAccLedger(page = 1) { diff --git a/tests/PolyTrader.Tests/Accounting/CurrencyStalenessTests.cs b/tests/PolyTrader.Tests/Accounting/CurrencyStalenessTests.cs new file mode 100644 index 0000000..6217845 --- /dev/null +++ b/tests/PolyTrader.Tests/Accounting/CurrencyStalenessTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.Accounting.Models; +using PolyTrader.Modules.Accounting.Persistence; +using PolyTrader.Modules.Accounting.Services; +using Xunit; + +namespace PolyTrader.Tests.Accounting +{ + /// + /// Die Warnung über veraltete EZB-Kurse (#533). Die Schwelle liegt bei drei Tagen, weil genau so + /// groß die planmäßige Wochenendlücke ist: Der Freitagskurs gilt bis Montag und ist dabei kein + /// Mangel. Erst darüber hinaus fehlt wirklich etwas. + /// + public class CurrencyStalenessTests + { + private sealed class StubFx : IFxRateRepository + { + private readonly List _rates; + public StubFx(params FxRate[] rates) => _rates = rates.ToList(); + public void Upsert(FxRate rate) => _rates.Add(rate); + public List GetAll() => _rates.OrderBy(r => r.Date).ToList(); + } + + private sealed class ThrowingFx : IFxRateRepository + { + public void Upsert(FxRate rate) => throw new InvalidOperationException("DB weg"); + public List GetAll() => throw new InvalidOperationException("DB weg"); + } + + /// Leeres Ledger: Hier zählt allein die Währungsauflösung, nicht die Aggregation. + private sealed class EmptyLedger : ILedgerRepository + { + public bool Upsert(LedgerEntry entry) => false; + public DateTime? LatestTimestamp(int accountId) => null; + public decimal SumNet(int accountId) => 0m; + public int Count(int accountId) => 0; + public List Query(int? accountId, DateTime? from, DateTime? to, int limit) => new(); + public List GetUpTo(int? accountId, DateTime to) => new(); + } + + private static AccountingReportService Build(IFxRateRepository fx) => new(new EmptyLedger(), fx); + + private static FxRate Rate(int year, int month, int day, decimal usdToEur = 0.86m) => + new() { Date = new DateTime(year, month, day), UsdToEur = usdToEur, Source = "ECB" }; + + [Fact] + public void Frisch_gerechneter_Kurs_warnt_nicht() + { + var ctx = Build(new StubFx(Rate(2026, 9, 9))).ResolveCurrency("EUR", new DateTime(2026, 9, 10)); + + Assert.True(ctx.Available); + Assert.False(ctx.IsStale); + Assert.Null(ctx.Warning); + Assert.Equal(1, ctx.RateAgeDays); + Assert.Equal(new DateTime(2026, 9, 9), ctx.RateDate); + } + + [Fact] + public void Wochenende_gilt_noch_nicht_als_veraltet() + { + // Freitag 2026-09-04 → Montag 2026-09-07: drei Tage, der Normalfall. + var ctx = Build(new StubFx(Rate(2026, 9, 4))).ResolveCurrency("EUR", new DateTime(2026, 9, 7)); + + Assert.Equal(3, ctx.RateAgeDays); + Assert.False(ctx.IsStale); + } + + [Fact] + public void Aelter_als_drei_Tage_warnt() + { + var ctx = Build(new StubFx(Rate(2026, 9, 4))).ResolveCurrency("EUR", new DateTime(2026, 9, 8)); + + Assert.True(ctx.Available); // benutzbar bleibt er + Assert.True(ctx.IsStale); + Assert.Equal(4, ctx.RateAgeDays); + Assert.Contains("04.09.2026", ctx.Warning); + Assert.Contains("4 Tage", ctx.Warning); + } + + [Fact] + public void Ohne_Kurse_ist_EUR_nicht_verfuegbar() + { + var ctx = Build(new StubFx()).ResolveCurrency("EUR", new DateTime(2026, 9, 10)); + + Assert.False(ctx.Available); + Assert.Equal(0m, ctx.Factor); + Assert.Contains("Keine EZB-Tageskurse", ctx.Note); + } + + [Fact] + public void Rueckwirkende_Abrechnung_nimmt_den_Kurs_von_damals() + { + var fx = new StubFx(Rate(2026, 3, 31, 0.92m), Rate(2026, 9, 9, 0.86m)); + + var maerz = Build(fx).ResolveCurrency("EUR", new DateTime(2026, 3, 31)); + + Assert.Equal(0.92m, maerz.Factor); // nicht der heutige Kurs + Assert.False(maerz.IsStale); // am Stichtag war er taggenau + } + + [Fact] + public void Datenbankausfall_sperrt_EUR_statt_zu_werfen() + { + var ctx = Build(new ThrowingFx()).ResolveCurrency("EUR", new DateTime(2026, 9, 10)); + + Assert.False(ctx.Available); + Assert.Equal(0m, ctx.Factor); + } + + [Fact] + public void USDC_und_USD_bleiben_ohne_Kurse_verfuegbar() + { + var svc = Build(new StubFx()); + + var usdc = svc.ResolveCurrency("USDC", new DateTime(2026, 9, 10)); + var usd = svc.ResolveCurrency("USD", new DateTime(2026, 9, 10)); + + Assert.True(usdc.Available); + Assert.Equal(1.0m, usdc.Factor); + Assert.True(usd.Available); + Assert.False(usd.IsStale); + } + } +} diff --git a/tests/PolyTrader.Tests/Accounting/EcbFxRateSourceTests.cs b/tests/PolyTrader.Tests/Accounting/EcbFxRateSourceTests.cs new file mode 100644 index 0000000..5cb73c1 --- /dev/null +++ b/tests/PolyTrader.Tests/Accounting/EcbFxRateSourceTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Accounting.Models; +using PolyTrader.Modules.Accounting.Persistence; +using PolyTrader.Modules.Accounting.Services; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests.Accounting +{ + /// + /// Prüft das Einlesen der EZB-Kurse und den täglichen Abgleich (#533). Der Abruf selbst liegt + /// hinter und wird hier durch eine Attrappe ersetzt — die Tests + /// laufen ohne Netz. + /// + public class EcbFxRateSourceTests + { + /// Echte Antwort der EZB-Daten-API, gekürzt auf die hier interessanten Spalten. + private const string RealCsv = + "KEY,FREQ,CURRENCY,CURRENCY_DENOM,EXR_TYPE,EXR_SUFFIX,TIME_PERIOD,OBS_VALUE,OBS_STATUS,TITLE_COMPL\n" + + "EXR.D.USD.EUR.SP00.A,D,USD,EUR,SP00,A,2026-09-08,1.1614,A,\"ECB reference exchange rate, US dollar/Euro, 2.15 pm (C.E.T.)\"\n" + + "EXR.D.USD.EUR.SP00.A,D,USD,EUR,SP00,A,2026-09-09,1.1652,A,\"ECB reference exchange rate, US dollar/Euro, 2.15 pm (C.E.T.)\"\n"; + + [Fact] + public void ParseCsv_kehrt_die_Kursrichtung_um() + { + var rates = EcbFxRateSource.ParseCsv(RealCsv); + + Assert.Equal(2, rates.Count); + + // Die Reihe liefert USD je EUR (1 EUR = 1,1652 USD). Gespeichert wird USD→EUR. + var newest = rates.Last(); + Assert.Equal(new DateTime(2026, 9, 9), newest.Date); + Assert.Equal(Math.Round(1m / 1.1652m, 6), newest.UsdToEur); + Assert.Equal("ECB", newest.Source); + + // Gegenprobe: 100 USD sind weniger als 100 EUR, solange der Euro über pari steht. + Assert.True(newest.UsdToEur < 1m); + } + + [Fact] + public void ParseCsv_uebergeht_Kommas_in_Anfuehrungszeichen() + { + // TITLE_COMPL enthält Kommas. Naives Splitten verschöbe die Spalten. + var rates = EcbFxRateSource.ParseCsv(RealCsv); + Assert.All(rates, r => Assert.True(r.UsdToEur > 0m)); + } + + [Theory] + [InlineData("")] + [InlineData("nur,eine,kopfzeile\n")] + public void ParseCsv_liefert_bei_unbrauchbarer_Antwort_nichts(string csv) + { + Assert.Empty(EcbFxRateSource.ParseCsv(csv)); + } + + [Fact] + public void ParseCsv_ueberspringt_Zeilen_ohne_Beobachtung() + { + string csv = + "TIME_PERIOD,OBS_VALUE\n" + + "2026-09-08,1.1614\n" + + "2026-09-09,\n" + // Feiertag: kein Wert + "2026-09-10,0\n"; // 0 wäre eine Division durch null + + var rates = EcbFxRateSource.ParseCsv(csv); + Assert.Single(rates); + Assert.Equal(new DateTime(2026, 9, 8), rates[0].Date); + } + + // ---- Abgleich ------------------------------------------------------ + + private sealed class FakeSource : IFxRateSource + { + public List Rates = new(); + public DateTime? AskedFrom; + public Exception? Throw; + + public Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) + { + AskedFrom = from; + if (Throw != null) throw Throw; + return Task.FromResult(Rates); + } + } + + private sealed class FakeRepo : IFxRateRepository + { + public readonly Dictionary Store = new(); + public void Upsert(FxRate rate) => Store[rate.Date.Date] = rate; + public List GetAll() => Store.Values.OrderBy(r => r.Date).ToList(); + } + + private static FxRateSyncService Build(FakeSource src, FakeRepo repo) => + new(src, repo, new TerminalLogger()); + + [Fact] + public async Task Erster_Lauf_holt_die_Historie() + { + var src = new FakeSource(); + var repo = new FakeRepo(); + var today = new DateTime(2026, 9, 10); + + await Build(src, repo).SyncAsync(today, CancellationToken.None); + + Assert.Equal(today.AddDays(-FxRateSyncService.InitialBackfillDays), src.AskedFrom); + } + + [Fact] + public async Task Folgelauf_holt_nur_den_Rand_nach() + { + var src = new FakeSource(); + var repo = new FakeRepo(); + repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 1), UsdToEur = 0.86m, Source = "ECB" }); + + await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None); + + Assert.Equal(new DateTime(2026, 9, 1).AddDays(-FxRateSyncService.IncrementalLookbackDays), src.AskedFrom); + } + + [Fact] + public async Task Neue_Kurse_werden_uebernommen_bekannte_nicht_erneut() + { + var src = new FakeSource(); + var repo = new FakeRepo(); + repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 8), UsdToEur = 0.861030m, Source = "ECB" }); + + src.Rates = new List + { + new() { Date = new DateTime(2026, 9, 8), UsdToEur = 0.861030m, Source = "ECB" }, // unverändert + new() { Date = new DateTime(2026, 9, 9), UsdToEur = 0.858221m, Source = "ECB" } // neu + }; + + int written = await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None); + + Assert.Equal(1, written); + Assert.Equal(2, repo.Store.Count); + } + + [Fact] + public async Task Abruffehler_laesst_den_Bestand_unangetastet() + { + var src = new FakeSource { Throw = new InvalidOperationException("EZB nicht erreichbar") }; + var repo = new FakeRepo(); + repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 1), UsdToEur = 0.86m, Source = "ECB" }); + + int written = await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None); + + Assert.Equal(0, written); + Assert.Single(repo.Store); // der alte Kurs gilt weiter + } + + /// + /// Der manuelle Anstoss aus dem WebUI und die stuendliche Schleife trafen sich im Testlauf auf + /// der VM in derselben Sekunde — im Log standen „280 Kurse uebernommen" und „53 Kurse + /// uebernommen" untereinander. Beide hatten die EZB gefragt. + /// + [Fact] + public async Task Gleichzeitige_Anstoesse_fragen_die_EZB_nur_einmal() + { + var repo = new FakeRepo(); + var src = new CountingSource(); + var svc = new FxRateSyncService(src, repo, new TerminalLogger()); + + await Task.WhenAll(Enumerable.Range(0, 8) + .Select(_ => svc.SyncDailyAsync(force: false, CancellationToken.None))); + + Assert.Equal(1, src.Calls); + } + + /// Quelle, die zaehlt und lange genug haelt, dass Aufrufe sich ueberlappen koennen. + private sealed class CountingSource : IFxRateSource + { + private int _calls; + public int Calls => _calls; + + public async Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) + { + Interlocked.Increment(ref _calls); + await Task.Delay(50, ct); + return new List + { + new() { Date = new DateTime(2026, 9, 9), UsdToEur = 0.858221m, Source = "ECB" } + }; + } + } + + [Fact] + public async Task SyncDaily_laeuft_hoechstens_einmal_je_Tag() + { + var src = new FakeSource(); + var repo = new FakeRepo(); + var svc = Build(src, repo); + + await svc.SyncDailyAsync(force: false, CancellationToken.None); + src.AskedFrom = null; + await svc.SyncDailyAsync(force: false, CancellationToken.None); + + Assert.Null(src.AskedFrom); // zweiter Aufruf hat nichts geholt + } + } +} diff --git a/tests/PolyTrader.Tests/AccountingTests.cs b/tests/PolyTrader.Tests/AccountingTests.cs index 6748258..31e7232 100644 --- a/tests/PolyTrader.Tests/AccountingTests.cs +++ b/tests/PolyTrader.Tests/AccountingTests.cs @@ -127,8 +127,11 @@ namespace PolyTrader.Tests var act = new FakeActivitySource(); var bal = new FakeBalance(); var state = new TradingState(); + // Kursabgleich mit der Null-Quelle: Der Ingest-Test prueft das Buchen, nicht die EZB (#533). + var logger = new TerminalLogger(); + var fxSync = new FxRateSyncService(new NullFxRateSource(), new EfFxRateRepository(factory), logger); var svc = new AccountingIngestService(state, ledger, runs, raw, act, new NullTransferSource(), bal, - new AccountingSystemContracts(), new TerminalLogger()); + new AccountingSystemContracts(), logger, fxSync); return (svc, ledger, runs, act, bal); }