6 Commits
Author SHA1 Message Date
RichardandClaude Opus 5 b221e0447b Roadmap: Begruendungen zu Waehrungsfaehigkeit, #479 und #480
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:50:51 +02:00
RichardandClaude Opus 5 614392afc3 Handelshistorie fasst Optionsschluessel; Order-ID landet in der Bindung
Zwei Maengel, sichtbar geworden beim Pruefen der vier Tabellen nach dem
Order-Nachweis (#293).

core_trade_history.Symbol war varchar(20) und traegt seit der
Optionserweiterung den Instrumentenschluessel statt des Ticker-Symbols.
"MSFT|20260918|P|302.5" sind 21 Zeichen - gekuerzt auf
"MSFT|20260918|P|302." liest sich das als Strike 302. Ein still
verfaelschter Eintrag in der Handelshistorie ist schlimmer als gar
keiner. Jetzt 64 wie core_position.ContractKey.

MarkSubmittedAsync rief niemand auf: im synchronen Pfad gibt es keinen
beobachtbaren Zeitpunkt zwischen Absenden und Endstatus. BrokerOrderId
blieb dadurch leer, und der Abgleich beim Neustart haette keine Bindung
einer Order zuordnen koennen - genau die Aufgabe, fuer die die Spalte da
ist. Die ID wird jetzt beim Abschluss festgehalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:46:56 +02:00
RichardandClaude Opus 5 b517f7a637 OrderRequest entsteht aus dem Instrument, nicht aus Einzelfeldern
Beim ersten erfolgreichen Order-Nachweis (#293) gegen das Paper-Konto
lief die Order auf die falsche Notierung: geprueft, bepreist und
reserviert wurde SAP:EUR (XETRA, 188,74 EUR), ausgefuehrt wurde die
SAP-ADR in New York zu 216,68 USD. Das USD-Guthaben rutschte dadurch von
-3.751 auf -4.185 - also genau der Margin-Kredit, den Schicht C
verhindern soll, aufgenommen an der Sicherung vorbei.

Ursache: ExecutionService baute die OrderRequest aus Einzelfeldern und
setzte Currency nicht; die Eigenschaft fiel still auf USD zurueck. Es
gab keine Fehlermeldung, und die Buecher wiesen anschliessend eine
EUR-Position aus, die es beim Broker nie gab.

Behebung nicht durch Nachtragen der Zuweisung, sondern baulich:
OrderRequest.For(instrument, ...) uebertraegt die Identitaet
vollstaendig. Wer die Felder einzeln setzt, baut ein zweites, halb
ausgefuelltes Instrument - das darf im Orderpfad nicht mehr moeglich
sein.

Zwei Regressionstests, die genau diesen Fall fangen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:44:08 +02:00
RichardandClaude Opus 5 9e0d3b229a Kursabfrage nimmt das Instrument statt nur das Symbol
Ohne Waehrung wird jeder Kontrakt als USD gesucht: ein Wert, der nur in
EUR notiert, ist dann "nicht aufloesbar", und die Ursache steht
nirgends. Beim Order-Nachweis gegen MIGA:EUR genau so aufgetreten - die
Kontraktaufloesung fuer die ORDER war bereits waehrungsfaehig, die fuer
den KURS nicht, und damit brach die Kette eine Stufe vorher ab.

Dieselbe Aenderung ist die Voraussetzung fuer Optionskurse (#299): ohne
Kontraktangaben laesst sich eine Option ueberhaupt nicht abfragen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:41:26 +02:00
RichardandClaude Opus 5 2a73172e27 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>
2026-09-04 15:39:30 +02:00
RichardandClaude Opus 5 bd04642735 Bargeld je Waehrung: $LEDGER:ALL statt $LEDGER, und melden was ankam
Beim ersten echten Order-Nachweis gegen das Paper-Konto (#293) lehnte
Schicht C jeden Kauf mit "Waehrungsdeckung [USD]: benoetigt 344,
verfuegbar 0" ab, obwohl das Konto Bargeld hat.

Ursache: das nackte "$LEDGER" liefert nur die Zusammenfassung in
Kontobasiswaehrung, und die traegt als Waehrungskennung "BASE" - also
genau den Wert, den die Auswertung ueberspringen muss, damit dasselbe
Geld nicht zweimal zaehlt. Die Aufschluesselung blieb dadurch leer, und
eine leere Aufschluesselung heisst fuer Schicht C bewusst "Deckung
unbekannt, also ablehnen". Mit ":ALL" kommen die Bestaende je Waehrung.

Dazu eine Logzeile mit dem, was der Broker gemeldet hat. Ohne sie ist
eine Ablehnung durch Schicht C von aussen nicht von einem echten
Geldmangel zu unterscheiden - man sieht "verfuegbar 0" und weiss nicht,
ob kein Geld da ist oder keine Auskunft kam. Genau diese Stunde hat der
Fund gekostet.

Gemessen am Paper-Konto DUR371528: EUR 91.832,22 / USD -3.751,01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:33:51 +02:00
25 changed files with 1694 additions and 68 deletions
+53
View File
@@ -403,6 +403,52 @@ DebugTrading-Modul (#468) platzieren echte Orders. Ihre Positionen dürfen nicht
eines Strategiemoduls landen – sie verfälschten dessen Auslastung, dessen GuV und dessen
Konzentration, und zwar dauerhaft, weil die Position echt ist.
### [O/K] Währung gehört zur Identität eines Instruments
Am 2026-09-04 stellte sich beim Order-Nachweis heraus, dass das Paper-Konto sein Bargeld in **EUR**
hält und in USD mit −3.751 im Minus steht. Ein USD-Kauf wäre damit ein USD-Kredit gewesen, und
Schicht C hat ihn zu Recht abgelehnt. Die Entscheidung fiel gegen ein Aussetzen der Sicherung und
für das, was das Problem an der Wurzel löst: **Instrumente sind währungsfähig.**
Die Währung steht dabei im Instrumentenschlüssel und nicht bloß als Attribut daneben. Derselbe
Ticker notiert an mehreren Plätzen — SAP gibt es in Frankfurt in EUR und als ADR in New York in
USD, zu deutlich verschiedenen Kursen. Zwei Notierungen als eine Position zu führen, würde Bestand
und Deckung vermischen.
**USD bleibt implizit** (`AAPL`, nicht `AAPL:USD`). Andernfalls wäre jede Bestandszeile ab sofort
ein anderes Instrument als vorher und aus einer Position würden zwei. Trennzeichen ist der
Doppelpunkt, nicht der Punkt — Ticker enthalten Punkte (BRK.B).
### [X] #479 — Die Order lief auf die falsche Notierung
Der teuerste Fund des Tages, und einer, den kein Unit-Test hätte finden können. Geprüft, bepreist
und reserviert wurde `SAP:EUR` zu 188,74 EUR. Ausgeführt wurde die SAP-ADR in New York zu 216,68
USD; das USD-Guthaben rutschte um 434 ins Minus.
Damit wurde **genau der Margin-Kredit aufgenommen, den Schicht C verhindern soll** — an der
Sicherung vorbei, weil die Sicherung ein anderes Instrument geprüft hatte als das, was gehandelt
wurde. Ohne Fehlermeldung. Die Bücher wiesen anschließend eine EUR-Position zum USD-Preis aus, die
es beim Broker nie gab.
Die Ursache war eine nicht gesetzte Eigenschaft: `ExecutionService` baute die `OrderRequest` aus
Einzelfeldern, und `Currency` fiel still auf USD zurück. Die Lehre ist allgemeiner als der Fehler:
**eine neue Dimension an der Identität eines Objekts macht jede Stelle, die dieses Objekt aus
Teilen zusammensetzt, zu einer Fehlerquelle.** Deshalb wurde nicht die Zuweisung nachgetragen,
sondern `OrderRequest.For(instrument, …)` eingeführt — im Orderpfad lässt sich ein Auftrag nicht
mehr aus losen Feldern bauen.
### [K] #480 — Das Kapitalmodell rechnet Währungen zusammen
Solange jedes Instrument USD war, war die Frage gegenstandslos. Mit der ersten EUR-Position ist sie
es nicht mehr: Pool, Konzentrationslimit und die Summe der offenen Reservierungen addieren Beträge
verschiedener Währungen unverändert.
Bei ähnlichen Kursen fällt das nicht auf — **genau deshalb ist es gefährlich.** Und die Richtung
des Fehlers ist nicht einheitlich: bei einer starken Fremdwährung wird die Auslastung
*unterschätzt*, und ein Buch darf mehr aufmachen als es soll. Das Kapitalmodell sieht die Lösung in
§7 bereits vor (Buch-Cash und Reservierungen je Währung); umgesetzt ist sie bisher nur für die
Reservierungen und für Schicht C. Für die Pool-Rechnung fehlen Umrechnungskurse — die liefert #321.
### [H] #293 — Wie der Nachweis geführt wird
`--ibkr-check` liest nur. Für #293 gibt es deshalb `--verify-order SYMBOL` am Daemon: es geht durch
@@ -415,6 +461,13 @@ Paper-Modus **und** außerhalb eines Paper-Ports (eines allein sagt nichts – e
einem Live-Port bewegt echtes Geld); und den globalen Handelsschalter übergeht er nicht, sonst
wäre der keiner.
**Geführt am 2026-09-04** gegen das Paper-Konto DUR371528: Kauf von 2 × `SAP:EUR` zu 185,86 EUR,
alle fünf Tabellen stimmen. Der Nachweis hat dabei **vier Fehler gefunden**, drei davon für
Unit-Tests unerreichbar — die falsche Notierung (#479), eine Bargeldaufschlüsselung, die gar nicht
ankam, eine Kursabfrage ohne Währung, und zwei Spalten der Buchführung, die zu eng waren bzw. leer
blieben. Das ist der eigentliche Ertrag: der Lauf gegen echte Gegenstellen prüft die Fugen zwischen
den Bausteinen, und dort saßen alle vier.
### [W] #309/#310 — Was steht, und was der Handelsbetrieb noch braucht
Gerüst und reine Strategie-Logik stehen: `ow_`-Schema mit fünf Tabellen, Zustandsautomat,
+10 -3
View File
@@ -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 =>
@@ -208,8 +214,9 @@ public sealed class CapitalService : ICapitalService
/// <inheritdoc />
public Task SettleReservationAsync(
long reservationId, int filledQuantity, decimal actualAmount, CancellationToken ct = default) =>
_reservations.SettleAsync(reservationId, filledQuantity, actualAmount, ct);
long reservationId, int filledQuantity, decimal actualAmount, string? brokerOrderId = null,
CancellationToken ct = default) =>
_reservations.SettleAsync(reservationId, filledQuantity, actualAmount, brokerOrderId, ct);
/// <inheritdoc />
public Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default) =>
@@ -27,14 +27,15 @@ 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
/// (mit Puffer) und tatsächlichem Bedarf fließt zurück in den Pool.
/// </summary>
Task SettleReservationAsync(
long reservationId, int filledQuantity, decimal actualAmount, CancellationToken ct = default);
long reservationId, int filledQuantity, decimal actualAmount, string? brokerOrderId = null,
CancellationToken ct = default);
/// <summary>Gibt eine Bindung frei, ohne dass ausgeführt wurde.</summary>
Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default);
@@ -189,7 +189,8 @@ public sealed class ReservationService
/// <param name="filledQuantity">Tatsächlich ausgeführte Menge; 0 = nichts ausgeführt.</param>
/// <param name="actualAmount">Tatsächlich benötigtes Kapital (bei Teilfüllung anteilig).</param>
public async Task SettleAsync(
long id, int filledQuantity, decimal actualAmount, CancellationToken ct = default)
long id, int filledQuantity, decimal actualAmount, string? brokerOrderId = null,
CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var row = await db.Reservations.FindAsync([id], ct);
@@ -197,6 +198,12 @@ public sealed class ReservationService
row.UpdatedAt = DateTime.UtcNow;
// Die Order-ID spaetestens hier festhalten. Im synchronen Pfad gibt es keinen beobachtbaren
// Zeitpunkt zwischen Absenden und Endstatus, an dem MarkSubmittedAsync greifen koennte -
// ohne diese Zeile bliebe BrokerOrderId leer, und der Abgleich beim Neustart haette nichts,
// woran er eine Bindung einer Order zuordnen koennte.
if (!string.IsNullOrWhiteSpace(brokerOrderId)) row.BrokerOrderId = brokerOrderId;
if (filledQuantity <= 0)
{
row.State = ReservationState.Cancelled;
@@ -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);
@@ -55,7 +56,12 @@ public class CoreDbContext : DbContext
e.ToTable("core_trade_history");
e.HasKey(x => x.Id);
e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.Symbol).HasMaxLength(20);
// 64 wie core_position.ContractKey: hier steht seit der Optionserweiterung der
// Instrumentenschluessel und nicht mehr das nackte Ticker-Symbol. Bei 20 Zeichen wuerde
// "MSFT|20260918|P|302.5" auf "MSFT|20260918|P|302." gekuerzt - und das laesst sich als
// Strike 302 lesen. Ein still verfaelschter Eintrag in der Handelshistorie ist
// schlimmer als gar keiner.
e.Property(x => x.Symbol).HasMaxLength(64);
e.Property(x => x.Action).HasMaxLength(10);
e.Property(x => x.SignalId).HasMaxLength(64);
e.Property(x => x.IbkrOrderId).HasMaxLength(100);
@@ -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");
}
}
}
@@ -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("20260904134622_WidenTradeHistorySymbol")]
partial class WidenTradeHistorySymbol
{
/// <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(64)
.HasColumnType("varchar(64)");
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,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class WidenTradeHistorySymbol : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Symbol",
table: "core_trade_history",
type: "varchar(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(20)",
oldMaxLength: 20)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Symbol",
table: "core_trade_history",
type: "varchar(20)",
maxLength: 20,
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(64)",
oldMaxLength: 64)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}
@@ -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");
@@ -525,8 +530,8 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
@@ -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');
}
@@ -60,7 +60,7 @@ public sealed class ExecutionService : IExecutionService
}
// 2. Kurs
var quote = await _broker.GetQuoteAsync(signal.Symbol, ct);
var quote = await _broker.GetQuoteAsync(signal.Instrument, ct);
if (quote is null || quote.Last <= 0)
{
Journal(signal, TradeDecision.Skipped, DecisionReason.NoQuote, $"Kein Kurs für {signal.Symbol} verfügbar.");
@@ -129,15 +129,10 @@ public sealed class ExecutionService : IExecutionService
var reservationId = reservation.Reservation!.Id;
// 7. Order platzieren
var order = new OrderRequest
{
Symbol = signal.Symbol,
Side = signal.Side,
Quantity = decision.Quantity,
Type = orderType,
LimitPrice = signal.LimitPrice,
Option = signal.Option
};
// Aus dem Instrument gebaut, nicht aus Einzelfeldern: sonst faellt die Waehrung still auf
// USD zurueck und die Order trifft eine andere Notierung als die, die geprueft wurde.
var order = OrderRequest.For(
signal.Instrument, signal.Side, decision.Quantity, orderType, signal.LimitPrice);
var result = await _broker.PlaceOrderAsync(order, ct);
if (!result.Success)
@@ -160,7 +155,8 @@ public sealed class ExecutionService : IExecutionService
// 9. Bindung abschließen. Reserviert wurde mit Puffer, ausgeführt zum echten Preis – die
// Differenz fließt zurück in den Pool.
var actualAmount = result.FilledQuantity * result.AvgFillPrice * signal.Instrument.Multiplier;
await _capital.SettleReservationAsync(reservationId, result.FilledQuantity, actualAmount, ct);
await _capital.SettleReservationAsync(
reservationId, result.FilledQuantity, actualAmount, result.OrderId, ct);
Journal(signal, TradeDecision.Executed, DecisionReason.OrderPlaced,
$"Ausgeführt: {sideStr} {result.FilledQuantity}x {signal.Instrument.Key} @ {result.AvgFillPrice:F2}",
+8 -2
View File
@@ -6,8 +6,14 @@ namespace IBKRTrader.Core.Trading;
/// </summary>
public interface IBrokerClient
{
/// <summary>Aktuelle Kurs-Momentaufnahme oder null, wenn nicht verfügbar.</summary>
Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default);
/// <summary>
/// Aktuelle Kurs-Momentaufnahme oder <c>null</c>, wenn nicht verfügbar.
///
/// <para>Nimmt das <b>Instrument</b> und nicht nur das Symbol: ohne Währung wird jeder Kontrakt
/// als USD gesucht, und ein Wert, der nur in EUR notiert, ist dann schlicht "nicht auflösbar".
/// Ohne Kontraktangaben gälte dasselbe für jede Option.</para>
/// </summary>
Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default);
/// <summary>Kontostand-Momentaufnahme.</summary>
Task<AccountState> GetAccountStateAsync(CancellationToken ct = default);
@@ -53,14 +53,14 @@ public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, ID
$"TWS-Broker aktiv: {ibkr.Host}:{ibkr.Port} (Client {ibkr.ClientId}), Modus {mode}.");
}
public async Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default)
public async Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
var contract = await _connection!.ResolveContractAsync(InstrumentRef.ForStock(symbol), _requestTimeout, ct).ConfigureAwait(false);
var contract = await _connection!.ResolveContractAsync(instrument, _requestTimeout, ct).ConfigureAwait(false);
if (contract is null) return null;
return await _connection.RequestQuoteAsync(contract, symbol, _requestTimeout, ct).ConfigureAwait(false);
return await _connection.RequestQuoteAsync(contract, instrument.Symbol, _requestTimeout, ct).ConfigureAwait(false);
}
public async Task<AccountState> GetAccountStateAsync(CancellationToken ct = default)
@@ -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))
@@ -346,11 +346,17 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
_accounts[id] = slot;
try
{
// $LEDGER liefert zusaetzlich die Bargeldbestaende je Waehrung. Sie werden fuer die
// $LEDGER:ALL liefert die Bargeldbestaende JE WAEHRUNG. Sie werden fuer die
// Waehrungsdeckung gebraucht (Kapitalmodell §4 Schicht C): ein Kauf in X darf nur aus
// Cash in X finanziert werden, sonst entsteht ein Sollsaldo in X - ein Margin-Kredit,
// auch wenn das Konto in Summe positiv aussieht.
_socket.reqAccountSummary(id, "All", "NetLiquidation,AvailableFunds,TotalCashValue,$LEDGER");
//
// ":ALL" ist noetig und nicht kosmetisch. Das nackte "$LEDGER" liefert nur die
// Zusammenfassung in Kontobasiswaehrung, und die traegt als Waehrungskennung "BASE" -
// also genau den Wert, den die Auswertung ueberspringen muss, damit dasselbe Geld nicht
// zweimal zaehlt. Ergebnis war eine leere Aufschluesselung und damit eine Ablehnung
// jedes Kaufs durch Schicht C (am 2026-09-04 gegen das Paper-Gateway beobachtet).
_socket.reqAccountSummary(id, "All", "NetLiquidation,AvailableFunds,TotalCashValue,$LEDGER:ALL");
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
@@ -363,6 +369,16 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
return null;
}
// Was der Broker an Waehrungen gemeldet hat, gehoert ins Log: ohne diese Zeile ist eine
// Ablehnung durch Schicht C ("verfuegbar 0") von aussen nicht von einem echten
// Geldmangel zu unterscheiden.
_logger.Info(LogModule, slot.Cash.Count == 0
? "Konto: keine Bargeldaufschluesselung je Waehrung erhalten - Schicht C lehnt Kaeufe " +
"deshalb ab. Antwortet das Gateway nicht auf $LEDGER:ALL?"
: "Konto: Bargeld je Waehrung " + string.Join(", ",
slot.Cash.Select(kv => string.Create(CultureInfo.InvariantCulture,
$"{kv.Key} {kv.Value:N2}"))) + ".");
return new AccountState(
ReadDecimal(slot.Values, "NetLiquidation"),
ReadDecimal(slot.Values, "AvailableFunds"),
@@ -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;
@@ -19,7 +19,7 @@ public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader
public Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BrokerExecution>>(Array.Empty<BrokerExecution>());
public Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default)
public Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default)
=> Task.FromResult<Quote?>(null);
public Task<AccountState> GetAccountStateAsync(CancellationToken ct = default)
@@ -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
+36 -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,36 @@ 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>
/// Baut den Auftrag aus dem <b>Instrument</b> – der einzige Weg, der die Identität vollständig
/// überträgt.
///
/// <para><b>Warum es diese Fabrik gibt.</b> Wer die Felder einzeln setzt, kann die Währung
/// vergessen; sie fällt dann auf USD zurück. Am 2026-09-04 ist genau das passiert: geprüft und
/// reserviert wurde <c>SAP:EUR</c>, gehandelt wurde die SAP-ADR in New York – Kurs 188,74 EUR
/// gegen Ausführung zu 216,68 USD, und das USD-Guthaben rutschte um 434 ins Minus. Es gab keine
/// Fehlermeldung; die Bücher wiesen eine EUR-Position aus, die es beim Broker nie gab. Ein
/// Auftrag muss aus dem Instrument entstehen, sonst ist er ein zweites, halb ausgefülltes
/// Instrument.</para>
/// </summary>
public static OrderRequest For(
InstrumentRef instrument, TradeSide side, int quantity, OrderType type, decimal? limitPrice = null) =>
new()
{
Symbol = instrument.Symbol,
Side = side,
Quantity = quantity,
Type = type,
LimitPrice = limitPrice,
Option = instrument.Option,
Currency = instrument.Currency
};
}
/// <summary>Ergebnis einer Order-Platzierung.</summary>
+5 -3
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()}");
@@ -180,7 +182,7 @@ internal static class Program
foreach (var p in positions.Take(10))
Console.WriteLine($" {p}");
var quote = await broker.GetQuoteAsync(symbol, cts.Token).ConfigureAwait(false);
var quote = await broker.GetQuoteAsync(InstrumentRef.ForStock(symbol), cts.Token).ConfigureAwait(false);
Console.WriteLine(quote is null
? $"Kurs {symbol}: keiner geliefert (ohne Marktdaten-Abo bei geschlossener Börse normal)"
: $"Kurs {symbol}: {quote}");
+23 -11
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,11 +84,12 @@ internal static class VerifyOrder
return 1;
}
var quote = await broker.GetQuoteAsync(symbol, cts.Token).ConfigureAwait(false);
var quote = await broker.GetQuoteAsync(instrument, cts.Token).ConfigureAwait(false);
if (quote is null || quote.Last <= 0)
{
Console.Error.WriteLine($"ABBRUCH: kein Kurs für {symbol}. Bei geschlossener Börse und ohne " +
$"Marktdaten-Abo ist das normal – dann später erneut versuchen.");
Console.Error.WriteLine($"ABBRUCH: kein Kurs für {instrument}. Entweder ist der Kontrakt " +
$"nicht auflösbar (Symbol/Währung prüfen – die Meldung des Adapters " +
$"steht oben), oder die Börse ist zu und es liegt kein Marktdaten-Abo vor.");
return 1;
}
@@ -107,14 +111,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 +145,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 +173,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;
}
}
@@ -58,7 +58,7 @@ public class ExecutionServiceTests
private void ArrangeHappyPath()
{
EnableTrading();
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
_broker.GetQuoteAsync(Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
@@ -87,7 +87,7 @@ public class ExecutionServiceTests
public async Task NoQuote_Skips()
{
EnableTrading();
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
_broker.GetQuoteAsync(Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Quote?>(null));
var result = await CreateSut().ExecuteAsync(BuySignal);
@@ -100,7 +100,7 @@ public class ExecutionServiceTests
public async Task RiskRejects_Skips_WithReason()
{
EnableTrading();
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
_broker.GetQuoteAsync(Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
@@ -243,8 +243,45 @@ public class ExecutionServiceTests
await CreateSut().ExecuteAsync(BuySignal);
// Reserviert wurde mit Puffer, ausgefuehrt zum echten Preis: 5 x 100 = 500 fliessen als
// tatsaechlicher Bedarf zurueck, der Rest wird freigegeben.
await _capital.Received(1).SettleReservationAsync(1, 5, 500m, Arg.Any<CancellationToken>());
// tatsaechlicher Bedarf zurueck, der Rest wird freigegeben. Die Order-ID gehoert dazu -
// ohne sie kann der Abgleich beim Neustart keine Bindung einer Order zuordnen.
await _capital.Received(1).SettleReservationAsync(1, 5, 500m, "O1", Arg.Any<CancellationToken>());
}
[Fact]
public async Task TheOrderCarriesTheSignalsCurrency_NotTheDefault()
{
// Am 2026-09-04 gegen das Paper-Konto beobachtet: geprueft und reserviert wurde SAP:EUR,
// gehandelt wurde die SAP-ADR in New York, weil die OrderRequest aus Einzelfeldern gebaut
// wurde und die Waehrung dabei auf USD zurueckfiel. Ohne Fehlermeldung.
ArrangeHappyPath();
var signal = new TradeSignal
{
Symbol = "SAP", Side = TradeSide.Buy, SourceModule = "CT", Currency = "EUR"
};
await CreateSut().ExecuteAsync(signal);
await _broker.Received(1).PlaceOrderAsync(
Arg.Is<OrderRequest>(o => o.Currency == "EUR" && o.Instrument.Key == "SAP:EUR"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task TheOrderCarriesTheOptionContract()
{
ArrangeHappyPath();
var spec = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put);
var signal = new TradeSignal
{
Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT", Option = spec
};
await CreateSut().ExecuteAsync(signal);
await _broker.Received(1).PlaceOrderAsync(
Arg.Is<OrderRequest>(o => o.Option == spec && o.Instrument.Kind == InstrumentKind.Option),
Arg.Any<CancellationToken>());
}
[Fact]
@@ -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]