Logdateinamen haengen nicht mehr am Kalender der Host-Kultur
Im Logverzeichnis der Test-VM lagen neben 2026-09-03.jsonl auch 1405-06-12.jsonl und 2569-09-03.jsonl - dieselben Tage im Sonnen-Hidschra- und im buddhistischen Kalender. Eine interpolierte Zeichenkette formatiert mit CurrentCulture, und die bringt ihren Kalender mit. Betroffen waren beide Schreibpfade des LoggingService und die Lesestelle im DossierService, die denselben Namen wieder zusammensetzt. CA1305 greift hier nicht: die Regel sieht string.Format und ToString, nicht die interpolierte Form. Die .editorconfig deckt diesen Fall also nicht ab. FormattableString.Invariant an allen drei Stellen, dazu ein Regressionstest ueber fa-IR, th-TH und de-DE - mit Gegenbeweis, dass die naive Form tatsaechlich abweicht, damit der Test nicht eines Tages unbemerkt trivial wird. Ausserdem: Verweise auf die Bugtracker-Nummern richtiggestellt (488/489). 461 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ public interface ICapitalService
|
||||
/// <summary>
|
||||
/// Vermerkt, dass die Order beim Broker liegt – die Bindung bleibt bestehen.
|
||||
///
|
||||
/// <para><b>Der Gegenspieler von <see cref="CancelReservationAsync"/>.</b> Bis #489 gab es zu
|
||||
/// <para><b>Der Gegenspieler von <see cref="CancelReservationAsync"/>.</b> Bis #488 gab es zu
|
||||
/// einer nicht ausgeführten Order nur das Freigeben. Eine Order, die beim Broker wartet, wurde
|
||||
/// damit wie eine gescheiterte behandelt: Das Geld war wieder frei, die Order lebte weiter.</para>
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Core.Time;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace IBKRTrader.Core.Logging;
|
||||
@@ -92,8 +93,13 @@ public class LoggingService
|
||||
var dir = Path.Combine(LogBaseDir, e.Module);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt");
|
||||
var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}";
|
||||
// InvariantCulture ist hier kein Formalismus, sondern der Unterschied zwischen einer
|
||||
// Datei, die es gibt, und einer, die niemand findet: Eine interpolierte Zeichenkette
|
||||
// formatiert mit CurrentCulture, und die bringt ihren Kalender mit. Unter fa-IR steht
|
||||
// im Namen das Jahr 1405, unter th-TH 2569 - beobachtet am 2026-09-06 auf der Test-VM,
|
||||
// wo neben 2026-09-06.jsonl auch 1405-06-12.jsonl und 2569-09-03.jsonl lagen.
|
||||
var file = Path.Combine(dir, FormattableString.Invariant($"{e.Level}-{e.Timestamp:dd-MM-yy}.txt"));
|
||||
var line = FormattableString.Invariant($"[{e.Timestamp:HH:mm:ss}] {e.Message}");
|
||||
if (e.Exception != null)
|
||||
line += $"{Environment.NewLine} {e.Exception}";
|
||||
|
||||
@@ -115,7 +121,7 @@ public class LoggingService
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(LogBaseDir);
|
||||
var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
|
||||
var file = Path.Combine(LogBaseDir, FormattableString.Invariant($"{e.Timestamp:yyyy-MM-dd}.jsonl"));
|
||||
var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message;
|
||||
var json = LogJson.WriteLine(utc, e.Level, e.Module, message, cid);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ public enum TradeDecision
|
||||
Failed, // versucht, aber fehlgeschlagen (z. B. Order-Fehler)
|
||||
|
||||
/// <summary>
|
||||
/// Abgesendet, Ausgang offen – die Order liegt beim Broker (#489). Bewusst weder "Executed"
|
||||
/// Abgesendet, Ausgang offen – die Order liegt beim Broker (#488). Bewusst weder "Executed"
|
||||
/// noch "Failed": Beides wäre eine Behauptung über etwas, das noch nicht entschieden ist.
|
||||
/// </summary>
|
||||
Pending
|
||||
@@ -38,7 +38,7 @@ public enum DecisionReason
|
||||
// ----- Ausführung -----
|
||||
OrderPlaced, // Order erfolgreich platziert
|
||||
OrderFailed, // Broker/Order-Fehler beim Platzieren
|
||||
OrderWorking // Order liegt beim Broker, noch nicht ausgeführt (#489)
|
||||
OrderWorking // Order liegt beim Broker, noch nicht ausgeführt (#488)
|
||||
}
|
||||
|
||||
/// <summary>Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar).</summary>
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace IBKRTrader.Core.Trading;
|
||||
/// <summary>
|
||||
/// Wie eine Order beim Broker ausgegangen ist – <b>drei</b> Ausgänge, nicht zwei.
|
||||
///
|
||||
/// <para><b>Warum das der Kern von #489 ist.</b> Bis dahin kannte <see cref="OrderResult"/> nur
|
||||
/// <para><b>Warum das der Kern von #488 ist.</b> Bis dahin kannte <see cref="OrderResult"/> nur
|
||||
/// „erfolgreich" und „fehlgeschlagen". Damit ließ sich der häufigste Fall am Wochenende gar nicht
|
||||
/// ausdrücken: Die Order ist beim Broker <b>angenommen</b> und wartet auf die Börseneröffnung.
|
||||
/// Sie als Fehlschlag zu buchen, gibt das gebundene Kapital frei, während die Order weiterläuft –
|
||||
|
||||
@@ -136,7 +136,7 @@ public sealed class ExecutionService : IExecutionService
|
||||
|
||||
var result = await _broker.PlaceOrderAsync(order, ct);
|
||||
|
||||
// Drei Ausgänge, drei Wege – und der gefährliche ist der mittlere (#489). Eine Order, die
|
||||
// Drei Ausgänge, drei Wege – und der gefährliche ist der mittlere (#488). Eine Order, die
|
||||
// beim Broker liegt, ist WEDER ausgeführt NOCH gescheitert. Sie wie einen Fehlschlag zu
|
||||
// behandeln, gibt das gebundene Kapital frei, während die Order weiterläuft: Dasselbe Geld
|
||||
// wird ein zweites Mal vergeben, und die spätere Ausführung erzeugt eine Position, die
|
||||
|
||||
@@ -984,7 +984,7 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
|
||||
// Ordermeldungen zuerst und getrennt: Ob eine Meldung eine Order beendet, entscheidet der
|
||||
// Code – nicht die Tatsache, dass sie über den Fehler-Callback kam. IBKR schickt über
|
||||
// denselben Weg auch Hinweise zu angenommenen Orders (#489).
|
||||
// denselben Weg auch Hinweise zu angenommenen Orders (#488).
|
||||
if (id >= 0 && _orders.TryGetValue(id, out var order))
|
||||
{
|
||||
if (IbkrMapping.IsOrderNotice(errorCode))
|
||||
|
||||
@@ -353,7 +353,7 @@ internal static class IbkrMapping
|
||||
/// <summary>
|
||||
/// Meldungen, die zu einer Order gehören, sie aber <b>nicht</b> ablehnen.
|
||||
///
|
||||
/// <para><b>399 ist der Grund, aus dem es diese Liste gibt</b> (#489): IBKR schickt darunter
|
||||
/// <para><b>399 ist der Grund, aus dem es diese Liste gibt</b> (#488): IBKR schickt darunter
|
||||
/// Hinweise zu einer <i>angenommenen</i> Order – am Wochenende regelmäßig "Your order will not
|
||||
/// be placed at the exchange until Monday 09:00". Als Fehler gelesen, meldete der Adapter
|
||||
/// "Order abgelehnt", das Kapitalmodell gab die Bindung frei, und am Montag füllte eine Order,
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
/// globaler Kaufstopp – <b>und niemals ein automatischer Verkauf zur Heilung</b>. Eine Software,
|
||||
/// die in einen fallenden Markt hinein Positionen schließt, weil eine Kennzahl unterschritten
|
||||
/// ist, richtet mehr Schaden an als der Zustand, den sie beheben soll.</item>
|
||||
/// <item>Die offenen Bindungen gegen das Orderbuch des Brokers halten (#489). Eine Bindung, zu der
|
||||
/// <item>Die offenen Bindungen gegen das Orderbuch des Brokers halten (#488). Eine Bindung, zu der
|
||||
/// keine Order mehr liegt, kommt in Quarantäne statt in die Freigabe – es gibt zwei Erklärungen
|
||||
/// dafür, und sie führen zu entgegengesetzten Handlungen.</item>
|
||||
/// </list>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace IBKRTrader.Daemon;
|
||||
|
||||
/// <summary>
|
||||
/// Der Blick ins Orderbuch des Brokers – und der Abgleich mit dem, was wir selbst gebucht haben
|
||||
/// (#489).
|
||||
/// (#488).
|
||||
///
|
||||
/// <para><b>Wozu es das gibt.</b> Am 2026-09-06 stellte sich heraus, dass vier Orders bei
|
||||
/// geschlossener Börse angenommen und für Montag vorgemerkt wurden, während der Adapter sie als
|
||||
|
||||
@@ -56,7 +56,7 @@ internal static class Program
|
||||
// Optionskette und Bewertungsgrößen eines Basiswerts ansehen. Liest nur.
|
||||
var chainSymbol = ArgAfter(args, "--option-chain");
|
||||
|
||||
// Was liegt tatsächlich beim Broker? Der Gegencheck zur eigenen Buchführung (#489).
|
||||
// Was liegt tatsächlich beim Broker? Der Gegencheck zur eigenen Buchführung (#488).
|
||||
// Ohne ihn ist "die Order wurde abgelehnt" eine Vermutung, keine Feststellung.
|
||||
var openOrders = HasFlag(args, "--open-orders");
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ public static class AppHostBuilder
|
||||
{
|
||||
// Hier wird nur GEMELDET, nicht entschieden.
|
||||
//
|
||||
// Bis #489 lief an dieser Stelle RecoverAsync gegen eine leere Orderliste, weil offene
|
||||
// Bis #488 lief an dieser Stelle RecoverAsync gegen eine leere Orderliste, weil offene
|
||||
// Broker-Orders nicht abrufbar waren - jede ueberlebende Bindung landete damit sofort in
|
||||
// Quarantaene. Das war vertretbar, solange nie eine Bindung den Zustand "Submitted"
|
||||
// erreichte. Seit eine wartende Order ihre Bindung behaelt, waere es falsch: Eine voellig
|
||||
|
||||
@@ -75,7 +75,10 @@ public sealed class DossierService
|
||||
var to = times.Max().Date.AddDays(1);
|
||||
for (var day = from; day <= to; day = day.AddDays(1))
|
||||
{
|
||||
string path = Path.Combine(_logsDirectory, $"{day:yyyy-MM-dd}.jsonl");
|
||||
// Muss zeichengleich zu dem sein, was LoggingService schreibt - sonst sucht der
|
||||
// Supervisor unter einem Namen, den es nicht gibt.
|
||||
string path = Path.Combine(
|
||||
_logsDirectory, FormattableString.Invariant($"{day:yyyy-MM-dd}.jsonl"));
|
||||
if (!File.Exists(path)) continue;
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
|
||||
@@ -100,4 +100,54 @@ public class CultureIndependenceTests
|
||||
markdown.Should().NotContain("327,27");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Dateinamen (#489) ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Führt die Prüfung unter einer Kultur mit <b>anderem Kalender</b> aus. Das Dezimalkomma ist
|
||||
/// nur die halbe Gefahr: fa-IR rechnet im Sonnen-Hidschra-Kalender, th-TH im buddhistischen.
|
||||
/// </summary>
|
||||
private static void UnderCulture(string name, Action assert)
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(name);
|
||||
assert();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fa-IR")] // Sonnen-Hidschra: Jahr 1405 statt 2026
|
||||
[InlineData("th-TH")] // buddhistisch: Jahr 2569 statt 2026
|
||||
[InlineData("de-DE")]
|
||||
public void LogFileNames_DoNotDependOnTheHostCalendar(string culture)
|
||||
{
|
||||
// Am 2026-09-06 lagen auf der Test-VM neben 2026-09-06.jsonl auch 1405-06-12.jsonl und
|
||||
// 2569-09-03.jsonl. Eine interpolierte Zeichenkette formatiert mit CurrentCulture, und die
|
||||
// bringt ihren Kalender mit: Der Supervisor sucht dann eine Datei, die es nicht gibt, und
|
||||
// die Tagesrotation legt jeden Tag eine neue Reihe an.
|
||||
var day = new DateTime(2026, 9, 6, 14, 3, 0, DateTimeKind.Utc);
|
||||
|
||||
UnderCulture(culture, () =>
|
||||
{
|
||||
FormattableString.Invariant($"{day:yyyy-MM-dd}.jsonl").Should().Be("2026-09-06.jsonl");
|
||||
FormattableString.Invariant($"Info-{day:dd-MM-yy}.txt").Should().Be("Info-06-09-26.txt");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheNaiveInterpolation_IsWhatWentWrong()
|
||||
{
|
||||
// Der Gegenbeweis, damit dieser Test nicht versehentlich trivial wird: Ohne den
|
||||
// ausdruecklichen Formatanbieter geht es tatsaechlich schief. Faellt diese Zusicherung
|
||||
// eines Tages, ist die Gefahr weg - dann darf der Test verschwinden, nicht vorher.
|
||||
var day = new DateTime(2026, 9, 6, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
UnderCulture("fa-IR", () => $"{day:yyyy-MM-dd}".Should().NotBe("2026-09-06"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,10 +300,10 @@ public class ExecutionServiceTests
|
||||
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ─── Die Order, die beim Broker liegt (#489) ─────────────────────────────
|
||||
// ─── Die Order, die beim Broker liegt (#488) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <b>Der Test, um den es bei #489 geht.</b> Am 2026-09-06 wurden 67 Orders bei geschlossener
|
||||
/// <b>Der Test, um den es bei #488 geht.</b> Am 2026-09-06 wurden 67 Orders bei geschlossener
|
||||
/// Börse angenommen und für Montag vorgemerkt, während der Adapter sie als abgelehnt meldete und
|
||||
/// jede Bindung freigegeben wurde. Das Geld war wieder im Topf, die Orders lebten weiter.
|
||||
/// </summary>
|
||||
@@ -346,7 +346,7 @@ public class ExecutionServiceTests
|
||||
public async Task RejectedOrder_StillReleasesTheCapital()
|
||||
{
|
||||
// Die Gegenprobe: Bei einer beweisbar abgelehnten Order MUSS das Geld zurück. Sonst hätte
|
||||
// der Fix aus #489 nur die eine Klemme gegen die andere getauscht.
|
||||
// der Fix aus #488 nur die eine Klemme gegen die andere getauscht.
|
||||
ArrangeHappyPath();
|
||||
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(OrderResult.Fail("Order abgelehnt")));
|
||||
|
||||
@@ -397,7 +397,7 @@ public class IbkrMappingTests
|
||||
IbkrMapping.IsInformational(IbkrMapping.DelayedDataNotice).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ─── Ordermeldungen (#489) ─────────────────────────────────────────
|
||||
// ─── Ordermeldungen (#488) ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Der Fehler, der 67 Orders unsichtbar gemacht hat: IBKR schickt unter Code 399 Hinweise zu
|
||||
|
||||
Reference in New Issue
Block a user