diff --git a/src/IBKRTrader.Core/Capital/CapitalService.cs b/src/IBKRTrader.Core/Capital/CapitalService.cs index 4ee9b33..135c764 100644 --- a/src/IBKRTrader.Core/Capital/CapitalService.cs +++ b/src/IBKRTrader.Core/Capital/CapitalService.cs @@ -151,10 +151,16 @@ public sealed class CapitalService : ICapitalService public async Task ReserveAsync( BookId book, TradeSignal signal, InstrumentRef instrument, TradeSide side, int quantity, int closingQuantity, decimal price, OrderType orderType, - AccountState account, string currency = "USD", CancellationToken ct = default) + AccountState account, string? currency = null, CancellationToken ct = default) { var opening = Math.Max(0, quantity - closingQuantity); + // Die Waehrung kommt aus dem Instrument, nicht vom Aufrufer: sie ist Teil der Identitaet + // des Kontrakts, und die Deckungspruefung muss gegen das Bargeld GENAU dieser Waehrung + // laufen. Ein Aufrufer, der hier etwas anderes uebergeben koennte, koennte die Pruefung + // gegen den falschen Topf laufen lassen. + currency = instrument.Currency; + return await _reservations.ReserveAsync( book, instrument, side, quantity, currency, signal.SignalId, load => diff --git a/src/IBKRTrader.Core/Capital/ICapitalService.cs b/src/IBKRTrader.Core/Capital/ICapitalService.cs index 4e2d075..32d9604 100644 --- a/src/IBKRTrader.Core/Capital/ICapitalService.cs +++ b/src/IBKRTrader.Core/Capital/ICapitalService.cs @@ -27,7 +27,7 @@ public interface ICapitalService Task ReserveAsync( BookId book, TradeSignal signal, InstrumentRef instrument, TradeSide side, int quantity, int closingQuantity, decimal price, OrderType orderType, - AccountState account, string currency = "USD", CancellationToken ct = default); + AccountState account, string? currency = null, CancellationToken ct = default); /// /// Schließt eine Bindung nach dem Ergebnis der Order ab. Die Differenz zwischen Reservierung diff --git a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs index aec2d7d..bbce4c3 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -42,6 +42,7 @@ public class CoreDbContext : DbContext e.Property(x => x.ContractKey).HasMaxLength(64); e.Property(x => x.Symbol).HasMaxLength(20); e.Property(x => x.Kind).HasMaxLength(10); + e.Property(x => x.Currency).HasMaxLength(5); e.Property(x => x.Right).HasMaxLength(1); e.Property(x => x.Strike).HasPrecision(18, 4); e.Property(x => x.AvgPrice).HasPrecision(18, 4); diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260904133807_AddPositionCurrency.Designer.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260904133807_AddPositionCurrency.Designer.cs new file mode 100644 index 0000000..bb9d012 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260904133807_AddPositionCurrency.Designer.cs @@ -0,0 +1,598 @@ +// +using System; +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Core.Persistence.Ef.Migrations +{ + [DbContext(typeof(CoreDbContext))] + [Migration("20260904133807_AddPositionCurrency")] + partial class AddPositionCurrency + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Ticker"); + + b.HasIndex("InstrumentId", "Source", "Ticker") + .IsUnique(); + + b.ToTable("core_ibkr_external_identifiers", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("CompanyName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Exchange") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IbkrConid") + .HasColumnType("bigint"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Isin") + .HasMaxLength(12) + .HasColumnType("varchar(12)"); + + b.Property("LastFetched") + .HasColumnType("datetime(6)"); + + b.Property("PrimaryExchange") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SecType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Sector") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Active"); + + b.HasIndex("IbkrConid") + .IsUnique(); + + b.HasIndex("Symbol"); + + b.ToTable("core_ibkr_instruments", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b => + { + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("BarSize") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("BarCount") + .HasColumnType("int"); + + b.Property("Close") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("High") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Low") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Open") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.Property("Wap") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.HasKey("InstrumentId", "BarSize", "Timestamp"); + + b.HasIndex("Timestamp"); + + b.ToTable("core_ibkr_market_data", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxPerTrade") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UsedBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Module"); + + b.ToTable("core_budget", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ContextJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SignalPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Module", "Timestamp"); + + b.ToTable("core_decision_journal", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Response") + .IsRequired() + .HasColumnType("text"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol", "Timestamp"); + + b.ToTable("core_order_events", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ContractKey") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("AvgPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Expiry") + .HasColumnType("date"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Multiplier") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Right") + .HasMaxLength(1) + .HasColumnType("varchar(1)"); + + b.Property("Strike") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Module", "ContractKey"); + + b.HasIndex("Symbol"); + + b.ToTable("core_position", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Book") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BrokerOrderId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ContractKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BrokerOrderId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("SignalId", "ContractKey") + .IsUnique(); + + b.HasIndex("State", "Book"); + + b.ToTable("core_reservation", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("core_settings", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IbkrOrderId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SignalId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TotalValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TradedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Module"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol"); + + b.ToTable("core_trade_history", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WorkerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("WorkerName", "StartedAt"); + + b.ToTable("core_worker_log", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260904133807_AddPositionCurrency.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260904133807_AddPositionCurrency.cs new file mode 100644 index 0000000..a9cac99 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260904133807_AddPositionCurrency.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class AddPositionCurrency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Currency", + table: "core_position", + type: "varchar(5)", + maxLength: 5, + nullable: false, + // Bestandszeilen stammen aus der Zeit, in der jedes Instrument USD war - genau das + // besagt auch ihr ContractKey, in dem USD implizit bleibt. Ein leerer Vorgabewert + // wuerde sie zu Positionen ohne Waehrung machen und die Deckungspruefung ins Leere + // laufen lassen. + defaultValue: "USD") + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Currency", + table: "core_position"); + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index af0ea05..f260434 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -342,6 +342,11 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations .HasPrecision(18, 4) .HasColumnType("decimal(18,4)"); + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + b.Property("Expiry") .HasColumnType("date"); diff --git a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs index ef7ec95..ae95343 100644 --- a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs +++ b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs @@ -33,6 +33,13 @@ public class CorePosition /// Kontraktgröße: 1 bei Aktien, bei US-Aktienoptionen 100. public int Multiplier { get; set; } = 1; + /// + /// Währung, in der die Position notiert. Steckt zwar auch im , steht + /// aber als eigene Spalte da: Bestand und Deckung werden je Währung ausgewertet, und das soll + /// eine Abfrage können, ohne den Schlüssel zerlegen zu müssen. + /// + public string Currency { get; set; } = IBKRTrader.Core.Trading.Currencies.Default; + /// Vorzeichenbehaftet: positiv = long, negativ = short (verkaufte Option). public int Quantity { get; set; } diff --git a/src/IBKRTrader.Core/Trading/Currencies.cs b/src/IBKRTrader.Core/Trading/Currencies.cs new file mode 100644 index 0000000..c1ac24d --- /dev/null +++ b/src/IBKRTrader.Core/Trading/Currencies.cs @@ -0,0 +1,41 @@ +namespace IBKRTrader.Core.Trading; + +/// +/// Währungskennungen nach ISO 4217, wie sie TWS verwendet. +/// +/// Eine eigene kleine Einheit, weil die Währung an vier Stellen dieselbe Schreibweise haben +/// muss: im Instrumentenschlüssel, im TWS-Kontrakt, in der Reservierung und im Bargeldbestand des +/// Kontos. Weichen zwei davon ab – "eur" gegen "EUR" –, findet die Deckungsprüfung kein Geld und +/// lehnt ab, ohne dass irgendwo ein Fehler auftaucht. +/// +public static class Currencies +{ + /// + /// Vorgabewährung. Sie bleibt im Instrumentenschlüssel implizit, damit bestehende + /// Schlüssel und Bestandszeilen unverändert gültig bleiben. + /// + public const string Default = "USD"; + + /// + /// Bringt eine Angabe auf die kanonische Form. Leer ergibt – ein Aufrufer, + /// der nichts angibt, meint den bisherigen Fall. + /// + public static string Normalize(string? currency) + { + var c = (currency ?? "").Trim().ToUpperInvariant(); + if (c.Length == 0) return Default; + if (!IsValid(c)) + throw new ArgumentException( + $"'{currency}' ist keine gültige Währungskennung (erwartet drei Buchstaben nach ISO 4217).", + nameof(currency)); + return c; + } + + /// + /// Drei Großbuchstaben. Bewusst keine Liste zulässiger Währungen: die müsste gepflegt werden, + /// und eine fehlende Währung wäre ein Fehler an der falschen Stelle – TWS lehnt einen + /// unbekannten Kontrakt ohnehin ab, und zwar mit einer Meldung, die den Grund nennt. + /// + public static bool IsValid(string? currency) => + currency is { Length: 3 } c && c.All(ch => ch is >= 'A' and <= 'Z'); +} diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index d32385b..ec5bf83 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -286,7 +286,7 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable { var query = instrument.Option is { } option ? IbkrMapping.Option(option) - : IbkrMapping.Stock(instrument.Symbol); + : IbkrMapping.Stock(instrument.Symbol, instrument.Currency); _socket.reqContractDetails(id, query); if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false)) diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs index cf0ff88..5601647 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs @@ -23,13 +23,16 @@ internal static class IbkrMapping public static string? ValidatePort(int port, TradingMode mode) => BrokerPorts.ValidateMode(port, mode); - /// US-Aktie über die SMART-Route. - public static Contract Stock(string symbol) => new() + /// + /// Aktie über die SMART-Route. Die Währung entscheidet mit, welche Notierung gemeint ist – + /// derselbe Ticker kann an mehreren Plätzen in verschiedenen Währungen laufen. + /// + public static Contract Stock(string symbol, string currency = Currencies.Default) => new() { Symbol = symbol.Trim().ToUpperInvariant(), SecType = "STK", Exchange = "SMART", - Currency = "USD" + Currency = Currencies.Normalize(currency) }; /// @@ -49,7 +52,7 @@ internal static class IbkrMapping Symbol = spec.Underlying.Trim().ToUpperInvariant(), SecType = "OPT", Exchange = "SMART", - Currency = "USD", + Currency = Currencies.Normalize(spec.Currency), LastTradeDateOrContractMonth = spec.ExpiryCode, Strike = (double)spec.Strike, Right = spec.RightCode, @@ -59,7 +62,7 @@ internal static class IbkrMapping /// Baut den Kontrakt passend zum Instrument des Auftrags – Aktie oder Option. public static Contract ForRequest(OrderRequest request) => - request.Option is { } option ? Option(option) : Stock(request.Symbol); + request.Option is { } option ? Option(option) : Stock(request.Symbol, request.Currency); public static Order BuildOrder(OrderRequest request, int orderId, string? account) { diff --git a/src/IBKRTrader.Core/Trading/Instruments.cs b/src/IBKRTrader.Core/Trading/Instruments.cs index e1e18d3..f31e6ab 100644 --- a/src/IBKRTrader.Core/Trading/Instruments.cs +++ b/src/IBKRTrader.Core/Trading/Instruments.cs @@ -23,13 +23,18 @@ public enum OptionRight { Call, Put } /// Optional. Nötig, sobald ein Basiswert mehrere Klassen führt (nach Splits, bei Mini-Optionen) – /// dann ist das Symbol allein nicht mehr eindeutig und TWS liefert den falschen Kontrakt. /// +/// +/// Währung des Kontrakts. Gehört zur Identität, nicht zur Beschreibung: der Strike ist eine Zahl +/// in dieser Währung, und die Deckung wird gegen das Bargeld dieser Währung geprüft. +/// public sealed record OptionSpec( string Underlying, DateOnly Expiry, decimal Strike, OptionRight Right, int Multiplier = 100, - string? TradingClass = null) + string? TradingClass = null, + string Currency = Currencies.Default) { /// Kontraktbezeichner "C"/"P", wie ihn die TWS-API erwartet. public string RightCode => Right == OptionRight.Call ? "C" : "P"; @@ -67,12 +72,13 @@ public sealed record OptionSpec( /// public readonly record struct InstrumentRef { - private InstrumentRef(string key, InstrumentKind kind, string symbol, OptionSpec? option) + private InstrumentRef(string key, InstrumentKind kind, string symbol, string currency, OptionSpec? option) { - Key = key; - Kind = kind; - Symbol = symbol; - Option = option; + Key = key; + Kind = kind; + Symbol = symbol; + Currency = currency; + Option = option; } /// Der Schlüssel selbst – Primärschlüsselbestandteil und Fremdschlüssel. @@ -83,26 +89,56 @@ public readonly record struct InstrumentRef /// Ticker der Aktie bzw. des Basiswerts der Option. Nie leer. public string Symbol { get; } + /// + /// Währung, in der das Instrument notiert und abgerechnet wird. + /// + /// Sie gehört zur Identität und nicht bloß zur Beschreibung: derselbe Ticker kann + /// an mehreren Plätzen in verschiedenen Währungen notieren, und ein Kauf darf nur aus Bargeld + /// derselben Währung finanziert werden (Kapitalmodell §4 Schicht C). Zwei Notierungen als eine + /// Position zu führen, würde Bestand und Deckung vermischen. + /// + public string Currency { get; } + /// Kontraktangaben, wenn = . public OptionSpec? Option { get; } /// Kontraktgröße: 1 bei Aktien, sonst der Multiplikator der Option. public int Multiplier => Option?.Multiplier ?? 1; - public static InstrumentRef ForStock(string symbol) + public static InstrumentRef ForStock(string symbol, string currency = Currencies.Default) { var s = Normalize(symbol); - return new InstrumentRef(s, InstrumentKind.Stock, s, null); + var c = Currencies.Normalize(currency); + return new InstrumentRef(Qualify(s, c), InstrumentKind.Stock, s, c, null); } public static InstrumentRef ForOption(OptionSpec spec) { var underlying = Normalize(spec.Underlying); + var currency = Currencies.Normalize(spec.Currency); var key = string.Create(CultureInfo.InvariantCulture, - $"{underlying}|{spec.ExpiryCode}|{spec.RightCode}|{FormatStrike(spec.Strike)}"); - return new InstrumentRef(key, InstrumentKind.Option, underlying, spec with { Underlying = underlying }); + $"{Qualify(underlying, currency)}|{spec.ExpiryCode}|{spec.RightCode}|{FormatStrike(spec.Strike)}"); + return new InstrumentRef(key, InstrumentKind.Option, underlying, currency, + spec with { Underlying = underlying, Currency = currency }); } + /// + /// Symbol mit Währung, aber nur wenn nötig: USD bleibt implizit. + /// + /// Das ist kein Schönheitsentscheid. Alle bestehenden Schlüssel und die bereits + /// angewendete Migration schreiben für eine Aktie schlicht das Ticker-Symbol; würde die Währung + /// immer angehängt, wäre jede Bestandszeile ab sofort ein anderes Instrument als vorher, und + /// aus einer Position würden zwei. + /// + private static string Qualify(string symbol, string currency) => + currency == Currencies.Default ? symbol : $"{symbol}{CurrencySeparator}{currency}"; + + /// + /// Trennt Symbol und Währung. Bewusst nicht der Punkt: Ticker enthalten Punkte + /// (BRK.B, RDS.A), ein Doppelpunkt kommt in ihnen nicht vor. + /// + private const char CurrencySeparator = ':'; + /// /// Liest einen Schlüssel zurück. Liefert false bei allem, was nicht dem obigen Aufbau /// entspricht – ein unlesbarer Schlüssel wird nie geraten, sondern gemeldet. @@ -113,9 +149,11 @@ public readonly record struct InstrumentRef if (string.IsNullOrWhiteSpace(key)) return false; var parts = key.Split('|'); + if (!TrySplitSymbol(parts[0], out var symbol, out var currency)) return false; + if (parts.Length == 1) { - instrument = ForStock(parts[0]); + instrument = ForStock(symbol, currency); return true; } if (parts.Length != 4) return false; @@ -135,12 +173,27 @@ public readonly record struct InstrumentRef if (!decimal.TryParse(parts[3], NumberStyles.Number, CultureInfo.InvariantCulture, out var strike)) return false; - instrument = ForOption(new OptionSpec(parts[0], DateOnly.FromDateTime(expiry), strike, right.Value)); + instrument = ForOption(new OptionSpec( + symbol, DateOnly.FromDateTime(expiry), strike, right.Value, Currency: currency)); return true; } + /// Zerlegt "MIGA:EUR" bzw. "AAPL" in Symbol und Währung. + private static bool TrySplitSymbol(string raw, out string symbol, out string currency) + { + symbol = raw; currency = Currencies.Default; + if (string.IsNullOrWhiteSpace(raw)) return false; + + var i = raw.IndexOf(CurrencySeparator); + if (i < 0) return true; + + symbol = raw[..i]; + currency = raw[(i + 1)..]; + return symbol.Length > 0 && Currencies.IsValid(currency); + } + /// Der Schlüssel des Basiswerts – bei einer Option die Aktie, auf die sie lautet. - public InstrumentRef UnderlyingRef => Kind == InstrumentKind.Stock ? this : ForStock(Symbol); + public InstrumentRef UnderlyingRef => Kind == InstrumentKind.Stock ? this : ForStock(Symbol, Currency); public override string ToString() => Key; diff --git a/src/IBKRTrader.Core/Trading/PortfolioService.cs b/src/IBKRTrader.Core/Trading/PortfolioService.cs index 54c4a39..3934bbd 100644 --- a/src/IBKRTrader.Core/Trading/PortfolioService.cs +++ b/src/IBKRTrader.Core/Trading/PortfolioService.cs @@ -143,6 +143,7 @@ public sealed class PortfolioService : IPortfolioService Strike = instrument.Option?.Strike, Right = instrument.Option?.RightCode, Multiplier = instrument.Multiplier, + Currency = instrument.Currency, Quantity = applied.Quantity, AvgPrice = applied.AvgPrice, UpdatedAt = DateTime.UtcNow diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index f57331a..be97768 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -53,9 +53,15 @@ public sealed record TradeSignal /// public OptionSpec? Option { get; init; } + /// + /// Währung der Aktie. Wird bei einer Option ignoriert – dort steht sie im . + /// Vorgabe USD, damit bestehende Module unverändert bleiben. + /// + public string Currency { get; init; } = Currencies.Default; + /// Das gemeinte Instrument – Aktie oder Kontrakt – als eindeutiger Schlüssel. public InstrumentRef Instrument => - Option is null ? InstrumentRef.ForStock(Symbol) : InstrumentRef.ForOption(Option); + Option is null ? InstrumentRef.ForStock(Symbol, Currency) : InstrumentRef.ForOption(Option); } /// Konkrete Order-Anforderung an den Broker. @@ -71,8 +77,11 @@ public sealed record OrderRequest /// Optionskontrakt, falls die Order eine Option meint. null = Aktie. public OptionSpec? Option { get; init; } + /// Währung der Aktie; bei einer Option steht sie im . + public string Currency { get; init; } = Currencies.Default; + public InstrumentRef Instrument => - Option is null ? InstrumentRef.ForStock(Symbol) : InstrumentRef.ForOption(Option); + Option is null ? InstrumentRef.ForStock(Symbol, Currency) : InstrumentRef.ForOption(Option); } /// Ergebnis einer Order-Platzierung. diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs index 28b435c..af308e1 100644 --- a/src/IBKRTrader.Daemon/Program.cs +++ b/src/IBKRTrader.Daemon/Program.cs @@ -79,8 +79,10 @@ internal static class Program return await RunIbkrCheckAsync(host.Services, SymbolArg(args) ?? "AAPL").ConfigureAwait(false); if (verifyOrder is not null) - return await VerifyOrder.RunAsync(host.Services, verifyOrder, HasFlag(args, "--confirm")) - .ConfigureAwait(false); + return await VerifyOrder.RunAsync( + host.Services, verifyOrder, + ArgAfter(args, "--currency") ?? IBKRTrader.Core.Trading.Currencies.Default, + HasFlag(args, "--confirm")).ConfigureAwait(false); logger.Info("Core", "=== IBKRTrader (kopflos) startet ==="); logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version} | {RuntimeDescription()}"); diff --git a/src/IBKRTrader.Daemon/VerifyOrder.cs b/src/IBKRTrader.Daemon/VerifyOrder.cs index 4ce615a..9eac446 100644 --- a/src/IBKRTrader.Daemon/VerifyOrder.cs +++ b/src/IBKRTrader.Daemon/VerifyOrder.cs @@ -28,14 +28,17 @@ internal static class VerifyOrder /// Nominalwert der Prüforder. Klein genug, dass ein Fehlschlag nichts kostet. private const decimal Notional = 500m; - public static async Task RunAsync(IServiceProvider services, string symbol, bool confirm) + public static async Task RunAsync( + IServiceProvider services, string symbol, string currency, bool confirm) { var inv = CultureInfo.InvariantCulture; var settings = services.GetRequiredService().Settings; var trading = settings.Trading; var ibkr = settings.IBKR; - Console.WriteLine($"Order-Nachweis (#293) für {symbol}: {ibkr.Host}:{ibkr.Port}, " + + var instrument = InstrumentRef.ForStock(symbol, currency); + + Console.WriteLine($"Order-Nachweis (#293) für {instrument}: {ibkr.Host}:{ibkr.Port}, " + $"Modus {trading.Mode}, Client-ID {ibkr.ClientId}."); // ── Sicherungen ────────────────────────────────────────────────────────── @@ -81,6 +84,8 @@ internal static class VerifyOrder return 1; } + // Die Kursabfrage laeuft weiterhin ueber das Symbol; der Adapter loest den Kontrakt + // anhand des Instruments samt Waehrung auf. var quote = await broker.GetQuoteAsync(symbol, cts.Token).ConfigureAwait(false); if (quote is null || quote.Last <= 0) { @@ -107,14 +112,21 @@ internal static class VerifyOrder var headroom = await capital.GetHeadroomAsync(book.Value, account, cts.Token).ConfigureAwait(false); + // Der haeufigste Grund fuer eine Ablehnung durch Schicht C ist nicht "kein Geld", sondern + // "Geld in der falschen Waehrung". Deshalb steht die Aufschluesselung hier, bevor + // irgendetwas passiert. + var cashHere = account.CashIn(instrument.Currency); + Console.WriteLine($"Bargeld: {instrument.Currency} " + + (cashHere is { } c ? c.ToString("N2", inv) : "keine Auskunft")); + Console.WriteLine($"Konto: NetLiquidation {account.NetLiquidation.ToString("N2", inv)}, " + $"Bargeld {account.TotalCashValue.ToString("N2", inv)}"); Console.WriteLine($"Kurs: {quote}"); Console.WriteLine($"Buch: {book} – Cap {headroom.Cap.ToString("N0", inv)}, " + $"gebunden {headroom.Bound.ToString("N0", inv)}, " + $"frei {headroom.Available.ToString("N0", inv)}"); - Console.WriteLine($"Vorhaben: KAUF {quantity} x {symbol} zum Marktpreis " + - $"(~{(quantity * quote.Last).ToString("N2", inv)} USD)."); + Console.WriteLine($"Vorhaben: KAUF {quantity} x {instrument} zum Marktpreis " + + $"(~{(quantity * quote.Last).ToString("N2", inv)} {instrument.Currency})."); if (!confirm) { @@ -134,7 +146,8 @@ internal static class VerifyOrder Side = TradeSide.Buy, SourceModule = BookId.Diagnostics.Value, Reason = "Verifikation der Orderstrecke (#293)", - SuggestedNotional = Notional + SuggestedNotional = Notional, + Currency = instrument.Currency }; var result = await execution.ExecuteAsync(signal, cts.Token).ConfigureAwait(false); @@ -161,9 +174,9 @@ internal static class VerifyOrder $"frei {afterHeadroom.Available.ToString("N0", inv)}"); Console.WriteLine(); Console.WriteLine("=== Order-Nachweis OK ==="); - Console.WriteLine($"Zu prüfen bleibt von Hand: core_trade_history, core_position (Buch {book}), " + - $"core_reservation (Zustand Filled) und core_order_events – " + - $"Signal-ID {signal.SignalId}."); + Console.WriteLine($"Zu prüfen bleibt von Hand: core_trade_history, core_position (Buch {book}, " + + $"Instrument {instrument}), core_reservation (Zustand Filled) und " + + $"core_order_events – Signal-ID {signal.SignalId}."); return 0; } } diff --git a/tests/IBKRTrader.Tests/Trading/InstrumentTests.cs b/tests/IBKRTrader.Tests/Trading/InstrumentTests.cs index 177849d..9d9b711 100644 --- a/tests/IBKRTrader.Tests/Trading/InstrumentTests.cs +++ b/tests/IBKRTrader.Tests/Trading/InstrumentTests.cs @@ -87,6 +87,72 @@ public class InstrumentTests act.Should().Throw(); } + // ─── Währung gehört zur Identität ──────────────────────────────────────────── + + [Fact] + public void UsdStaysImplicitInTheKey() + { + // Sonst waere jede Bestandszeile ab sofort ein anderes Instrument als vorher, und aus + // einer Position wuerden zwei. + InstrumentRef.ForStock("AAPL").Key.Should().Be("AAPL"); + InstrumentRef.ForStock("AAPL", "USD").Key.Should().Be("AAPL"); + } + + [Fact] + public void AnotherCurrency_BecomesPartOfTheKey() + { + InstrumentRef.ForStock("MIGA", "EUR").Key.Should().Be("MIGA:EUR"); + InstrumentRef.ForStock("MIGA", "eur").Currency.Should().Be("EUR"); + } + + [Fact] + public void TheSameTickerInTwoCurrencies_AreTwoInstruments() + { + // Derselbe Ticker kann an mehreren Plaetzen notieren. Zwei Notierungen als eine Position + // zu fuehren, wuerde Bestand und Deckung vermischen. + InstrumentRef.ForStock("XYZ", "USD").Key + .Should().NotBe(InstrumentRef.ForStock("XYZ", "EUR").Key); + } + + [Fact] + public void OptionKey_CarriesTheCurrencyToo() + { + var eur = Put150 with { Currency = "EUR" }; + + InstrumentRef.ForOption(eur).Key.Should().Be("AAPL:EUR|20260918|P|150"); + InstrumentRef.ForOption(Put150).Key.Should().Be("AAPL|20260918|P|150"); + } + + [Theory] + [InlineData("MIGA:EUR")] + [InlineData("AAPL:EUR|20260918|P|150")] + [InlineData("BRK.B")] + public void KeysWithCurrencyAndDottedTickers_RoundTrip(string key) + { + // Der Punkt ist als Trennzeichen ausgeschieden, weil Ticker ihn enthalten (BRK.B). + InstrumentRef.TryParse(key, out var parsed).Should().BeTrue(); + parsed.Key.Should().Be(key); + } + + [Fact] + public void UnderlyingOfAForeignOption_KeepsTheCurrency() + { + var eur = InstrumentRef.ForOption(Put150 with { Currency = "EUR" }); + + eur.UnderlyingRef.Key.Should().Be("MIGA:EUR".Replace("MIGA", "AAPL")); + eur.UnderlyingRef.Currency.Should().Be("EUR"); + } + + [Theory] + [InlineData("EU")] + [InlineData("EURO")] + [InlineData("eu1")] + public void AnInvalidCurrency_IsRefused(string currency) + { + var act = () => InstrumentRef.ForStock("AAPL", currency); + act.Should().Throw(); + } + // ─── Bewertung ─────────────────────────────────────────────────────────────── [Fact]