Instrumente sind waehrungsfaehig

Die Waehrung gehoert zur Identitaet eines Instruments und nicht zu
seiner Beschreibung: derselbe Ticker kann an mehreren Plaetzen in
verschiedenen Waehrungen notieren, und ein Kauf darf nur aus Bargeld
derselben Waehrung finanziert werden (Kapitalmodell §4 Schicht C). Zwei
Notierungen als eine Position zu fuehren, wuerde Bestand und Deckung
vermischen.

USD bleibt im Instrumentenschluessel IMPLIZIT ("AAPL", nicht
"AAPL:USD"). Andernfalls waere jede Bestandszeile ab sofort ein anderes
Instrument als vorher und aus einer Position wuerden zwei. Trennzeichen
ist der Doppelpunkt und nicht der Punkt - Ticker enthalten Punkte
(BRK.B).

Neu: Currencies (kanonische Schreibweise an allen vier Stellen, die sie
gleich haben muessen), Currency an OptionSpec, InstrumentRef,
TradeSignal, OrderRequest und core_position. CapitalService nimmt die
Waehrung ab jetzt aus dem Instrument statt vom Aufrufer - wer sie
uebergeben koennte, koennte die Deckungspruefung gegen den falschen Topf
laufen lassen. --verify-order kennt --currency.

Anlass: das Paper-Konto haelt EUR 91.832 und USD -3.751. Ein
USD-Nachweis waere ein USD-Kredit gewesen und wurde von Schicht C
zu Recht abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-04 15:39:30 +02:00
co-authored by Claude Opus 5
parent bd04642735
commit 2a73172e27
16 changed files with 873 additions and 33 deletions
@@ -151,10 +151,16 @@ public sealed class CapitalService : ICapitalService
public async Task<ReservationService.ReserveResult> 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 =>
@@ -27,7 +27,7 @@ public interface ICapitalService
Task<ReservationService.ReserveResult> 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);
/// <summary>
/// Schließt eine Bindung nach dem Ergebnis der Order ab. Die Differenz zwischen Reservierung
@@ -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);
@@ -0,0 +1,598 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("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<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<decimal>("MaxPerTrade")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<DateOnly?>("Expiry")
.HasColumnType("date");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<int>("Multiplier")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Right")
.HasMaxLength(1)
.HasColumnType("varchar(1)");
b.Property<decimal?>("Strike")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Book")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("BrokerOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("ContractKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("Shares")
.HasColumnType("int");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<string>("Key")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("core_settings", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IbkrOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Message")
.HasColumnType("longtext");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("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
}
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddPositionCurrency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Currency",
table: "core_position");
}
}
}
@@ -342,6 +342,11 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<DateOnly?>("Expiry")
.HasColumnType("date");
@@ -33,6 +33,13 @@ public class CorePosition
/// <summary>Kontraktgröße: 1 bei Aktien, bei US-Aktienoptionen 100.</summary>
public int Multiplier { get; set; } = 1;
/// <summary>
/// Währung, in der die Position notiert. Steckt zwar auch im <see cref="ContractKey"/>, 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.
/// </summary>
public string Currency { get; set; } = IBKRTrader.Core.Trading.Currencies.Default;
/// <summary>Vorzeichenbehaftet: positiv = long, negativ = short (verkaufte Option).</summary>
public int Quantity { get; set; }
+41
View File
@@ -0,0 +1,41 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Währungskennungen nach ISO 4217, wie sie TWS verwendet.
///
/// <para>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.</para>
/// </summary>
public static class Currencies
{
/// <summary>
/// Vorgabewährung. Sie bleibt im Instrumentenschlüssel <b>implizit</b>, damit bestehende
/// Schlüssel und Bestandszeilen unverändert gültig bleiben.
/// </summary>
public const string Default = "USD";
/// <summary>
/// Bringt eine Angabe auf die kanonische Form. Leer ergibt <see cref="Default"/> – ein Aufrufer,
/// der nichts angibt, meint den bisherigen Fall.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static bool IsValid(string? currency) =>
currency is { Length: 3 } c && c.All(ch => ch is >= 'A' and <= 'Z');
}
@@ -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))
@@ -23,13 +23,16 @@ internal static class IbkrMapping
public static string? ValidatePort(int port, TradingMode mode) => BrokerPorts.ValidateMode(port, mode);
/// <summary>US-Aktie über die SMART-Route.</summary>
public static Contract Stock(string symbol) => new()
/// <summary>
/// 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.
/// </summary>
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)
};
/// <summary>
@@ -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
/// <summary>Baut den Kontrakt passend zum Instrument des Auftrags – Aktie oder Option.</summary>
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)
{
+62 -9
View File
@@ -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.
/// </param>
/// <param name="Currency">
/// 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 <i>dieser</i> Währung geprüft.
/// </param>
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)
{
/// <summary>Kontraktbezeichner "C"/"P", wie ihn die TWS-API erwartet.</summary>
public string RightCode => Right == OptionRight.Call ? "C" : "P";
@@ -67,11 +72,12 @@ public sealed record OptionSpec(
/// </summary>
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;
Currency = currency;
Option = option;
}
@@ -83,26 +89,56 @@ public readonly record struct InstrumentRef
/// <summary>Ticker der Aktie bzw. des Basiswerts der Option. Nie leer.</summary>
public string Symbol { get; }
/// <summary>
/// Währung, in der das Instrument notiert und abgerechnet wird.
///
/// <para>Sie gehört zur <b>Identität</b> 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.</para>
/// </summary>
public string Currency { get; }
/// <summary>Kontraktangaben, wenn <see cref="Kind"/> = <see cref="InstrumentKind.Option"/>.</summary>
public OptionSpec? Option { get; }
/// <summary>Kontraktgröße: 1 bei Aktien, sonst der Multiplikator der Option.</summary>
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 });
}
/// <summary>
/// Symbol mit Währung, aber nur wenn nötig: <c>USD</c> bleibt <b>implizit</b>.
///
/// <para>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.</para>
/// </summary>
private static string Qualify(string symbol, string currency) =>
currency == Currencies.Default ? symbol : $"{symbol}{CurrencySeparator}{currency}";
/// <summary>
/// Trennt Symbol und Währung. Bewusst <b>nicht</b> der Punkt: Ticker enthalten Punkte
/// (BRK.B, RDS.A), ein Doppelpunkt kommt in ihnen nicht vor.
/// </summary>
private const char CurrencySeparator = ':';
/// <summary>
/// Liest einen Schlüssel zurück. Liefert <c>false</c> 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;
}
/// <summary>Zerlegt "MIGA:EUR" bzw. "AAPL" in Symbol und Währung.</summary>
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);
}
/// <summary>Der Schlüssel des Basiswerts – bei einer Option die Aktie, auf die sie lautet.</summary>
public InstrumentRef UnderlyingRef => Kind == InstrumentKind.Stock ? this : ForStock(Symbol);
public InstrumentRef UnderlyingRef => Kind == InstrumentKind.Stock ? this : ForStock(Symbol, Currency);
public override string ToString() => Key;
@@ -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
+11 -2
View File
@@ -53,9 +53,15 @@ public sealed record TradeSignal
/// </summary>
public OptionSpec? Option { get; init; }
/// <summary>
/// Währung der Aktie. Wird bei einer Option ignoriert – dort steht sie im <see cref="Option"/>.
/// Vorgabe USD, damit bestehende Module unverändert bleiben.
/// </summary>
public string Currency { get; init; } = Currencies.Default;
/// <summary>Das gemeinte Instrument – Aktie oder Kontrakt – als eindeutiger Schlüssel.</summary>
public InstrumentRef Instrument =>
Option is null ? InstrumentRef.ForStock(Symbol) : InstrumentRef.ForOption(Option);
Option is null ? InstrumentRef.ForStock(Symbol, Currency) : InstrumentRef.ForOption(Option);
}
/// <summary>Konkrete Order-Anforderung an den Broker.</summary>
@@ -71,8 +77,11 @@ public sealed record OrderRequest
/// <summary>Optionskontrakt, falls die Order eine Option meint. <c>null</c> = Aktie.</summary>
public OptionSpec? Option { get; init; }
/// <summary>Währung der Aktie; bei einer Option steht sie im <see cref="Option"/>.</summary>
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);
}
/// <summary>Ergebnis einer Order-Platzierung.</summary>
+4 -2
View File
@@ -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()}");
+21 -8
View File
@@ -28,14 +28,17 @@ internal static class VerifyOrder
/// <summary>Nominalwert der Prüforder. Klein genug, dass ein Fehlschlag nichts kostet.</summary>
private const decimal Notional = 500m;
public static async Task<int> RunAsync(IServiceProvider services, string symbol, bool confirm)
public static async Task<int> RunAsync(
IServiceProvider services, string symbol, string currency, bool confirm)
{
var inv = CultureInfo.InvariantCulture;
var settings = services.GetRequiredService<SettingsService>().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;
}
}
@@ -87,6 +87,72 @@ public class InstrumentTests
act.Should().Throw<ArgumentException>();
}
// ─── 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<ArgumentException>();
}
// ─── Bewertung ───────────────────────────────────────────────────────────────
[Fact]