diff --git a/src/IBKRTrader.Core/Capital/CapitalModels.cs b/src/IBKRTrader.Core/Capital/CapitalModels.cs index c961440..a5a1bab 100644 --- a/src/IBKRTrader.Core/Capital/CapitalModels.cs +++ b/src/IBKRTrader.Core/Capital/CapitalModels.cs @@ -21,7 +21,19 @@ public enum LimitKind Coverage, /// Der Watchdog hat den Kauf global gesperrt (§4 Schicht D). - BuyingHalted + BuyingHalted, + + /// + /// Der Betreiber hat diese Position gesperrt (#485). Je nach Stufe betrifft das nur das + /// Aufstocken, nur das Abbauen oder beides. + /// + PositionLocked, + + /// Der Betreiber hat dieses Instrument vom Handel ausgeschlossen (#486). + InstrumentBlocked, + + /// Die harte Bestandsobergrenze des Instruments ist erreicht (#486). + InstrumentQuantity } /// diff --git a/src/IBKRTrader.Core/Capital/CapitalService.cs b/src/IBKRTrader.Core/Capital/CapitalService.cs index 6da3bae..cfc860c 100644 --- a/src/IBKRTrader.Core/Capital/CapitalService.cs +++ b/src/IBKRTrader.Core/Capital/CapitalService.cs @@ -19,6 +19,7 @@ public sealed class CapitalService : ICapitalService private readonly IPortfolioService _portfolio; private readonly ReservationService _reservations; private readonly BookRegistry _books; + private readonly TradingRuleService _rules; private readonly SettingsService _settings; private readonly LoggingService _logger; @@ -26,9 +27,11 @@ public sealed class CapitalService : ICapitalService IPortfolioService portfolio, ReservationService reservations, BookRegistry books, + TradingRuleService rules, SettingsService settings, LoggingService logger) { + _rules = rules; _portfolio = portfolio; _reservations = reservations; _books = books; @@ -181,6 +184,13 @@ public sealed class CapitalService : ICapitalService var held = await _portfolio .GetPositionQuantityAsync(book.Value, instrument, ct).ConfigureAwait(false); + // Die Sperren des Betreibers (#485/#486). Der Bestand ueber ALLE Buecher ist die + // Bezugsgroesse der harten Obergrenze - kauft der Betreiber von Hand, sind die Module dort + // ebenso gesperrt wie bei der Konzentration. + var positionLock = await _rules.GetLockAsync(book, instrument, ct).ConfigureAwait(false); + var instrumentRule = await _rules.GetRuleAsync(instrument, ct).ConfigureAwait(false); + var heldTotal = all.Where(p => p.Instrument.Key == instrument.Key).Sum(p => p.Quantity); + return new RiskContext { Price = price, @@ -195,6 +205,9 @@ public sealed class CapitalService : ICapitalService // Auch ein Kaufstopp, solange die Umrechnung unvollständig ist: Cap, Spielraum und // Konzentration stehen dann auf Zahlen, deren Groesse wir nicht kennen. Verkaufen und // Schliessen bleiben moeglich - angehalten wird nur das Aufmachen. + PositionLock = positionLock, + InstrumentRule = instrumentRule, + HeldTotalQuantity = heldTotal, BuyingHalted = MarginGuard.BuyingShouldHalt( account, (decimal)Config.WatchdogMinCashPercent) || snapshot.FxIncomplete diff --git a/src/IBKRTrader.Core/Capital/TradingRuleService.cs b/src/IBKRTrader.Core/Capital/TradingRuleService.cs new file mode 100644 index 0000000..163193b --- /dev/null +++ b/src/IBKRTrader.Core/Capital/TradingRuleService.cs @@ -0,0 +1,221 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using IBKRTrader.Core.Trading; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Core.Capital; + +/// +/// Verwaltet die Sperren des Betreibers (#485/#486) und beantwortet sie im Risikopfad. +/// +/// Warum mit Zwischenspeicher. Die Regeln werden bei jeder Risikoprüfung +/// gebraucht, ändern sich aber selten – sie stammen von einem Menschen. Zwei Datenbankabfragen je +/// Signal wären reine Last. Der Speicher wird bei jeder Änderung verworfen, und er wird +/// vollständig geladen: eine halb geladene Sperrliste ist gefährlicher als keine, weil sie +/// aussieht, als sei alles geprüft. +/// +/// Fehlerverhalten. Lässt sich die Liste nicht laden, gilt das nicht als „keine +/// Sperren". Der Dienst meldet es und liefert die zuletzt bekannte Fassung; gab es noch keine, +/// wird gesperrt statt freigegeben. Eine Sperre, die bei einem Datenbankfehler verschwindet, wäre +/// keine. +/// +public sealed class TradingRuleService +{ + private const string LogModule = "Kapital"; + + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; + private readonly SemaphoreSlim _reload = new(1, 1); + + private Dictionary? _locks; + private Dictionary? _rules; + private bool _loadFailed; + + public TradingRuleService(IDbContextFactory dbf, LoggingService logger) + { + _dbf = dbf; + _logger = logger; + } + + /// Sperrstufe einer Position; , wenn keine gesetzt ist. + public async Task GetLockAsync(BookId book, InstrumentRef instrument, CancellationToken ct = default) + { + await EnsureLoadedAsync(ct).ConfigureAwait(false); + + // Konnte nicht geladen werden und es gibt keinen Stand: sperren statt durchlassen. + if (_locks is null) return PositionLock.Locked; + + return _locks.TryGetValue(Key(book, instrument), out var level) ? level : PositionLock.Free; + } + + /// + /// Regel eines Instruments. Geprüft wird erst der vollständige Schlüssel ("SAP:EUR"), dann das + /// reine Symbol ("SAP") – so lässt sich eine einzelne Notierung oder ein Wert als Ganzes + /// sperren. Die engere Angabe gewinnt. + /// + public async Task GetRuleAsync(InstrumentRef instrument, CancellationToken ct = default) + { + await EnsureLoadedAsync(ct).ConfigureAwait(false); + + if (_rules is null) return new InstrumentRule(Blocked: true); + if (_rules.Count == 0) return InstrumentRule.None; + + if (_rules.TryGetValue(instrument.Key, out var exact)) return exact; + return _rules.TryGetValue(instrument.Symbol, out var bySymbol) ? bySymbol : InstrumentRule.None; + } + + // ─── Setzen ────────────────────────────────────────────────────────────────── + + /// Setzt oder löst die Sperre einer Position. löscht sie. + public async Task SetLockAsync( + BookId book, InstrumentRef instrument, PositionLock level, string? note = null, + CancellationToken ct = default) + { + await using (var db = await _dbf.CreateDbContextAsync(ct)) + { + var row = await db.PositionLocks.FindAsync([book.Value, instrument.Key], ct); + + if (level == PositionLock.Free) + { + if (row is not null) db.PositionLocks.Remove(row); + } + else if (row is null) + { + db.PositionLocks.Add(new CorePositionLock + { + Book = book.Value, ContractKey = instrument.Key, + Level = level.ToString(), Note = note, UpdatedAt = DateTime.UtcNow + }); + } + else + { + row.Level = level.ToString(); + row.Note = note; + row.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(ct); + } + + Invalidate(); + _logger.Warn(LogModule, + $"Positionssperre {book.Value}/{instrument.Key}: {level}" + + (note is null ? "." : $" – {note}") + + (level == PositionLock.Locked + ? " ACHTUNG: Diese Stufe verhindert auch das Schließen durch Module." + : "")); + } + + /// Setzt oder löscht die Regel eines Instruments. + public async Task SetRuleAsync( + string contractKeyOrSymbol, InstrumentRule rule, string? note = null, + CancellationToken ct = default) + { + var key = contractKeyOrSymbol.Trim().ToUpperInvariant(); + + await using (var db = await _dbf.CreateDbContextAsync(ct)) + { + var row = await db.InstrumentRules.FindAsync([key], ct); + + if (!rule.IsRestrictive) + { + if (row is not null) db.InstrumentRules.Remove(row); + } + else if (row is null) + { + db.InstrumentRules.Add(new CoreInstrumentRule + { + ContractKey = key, Blocked = rule.Blocked, MaxQuantity = rule.MaxQuantity, + Note = note, UpdatedAt = DateTime.UtcNow + }); + } + else + { + row.Blocked = rule.Blocked; + row.MaxQuantity = rule.MaxQuantity; + row.Note = note; + row.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(ct); + } + + Invalidate(); + _logger.Warn(LogModule, + $"Instrumentenregel {key}: " + + (rule.Blocked ? "Handel gesperrt" : "Handel erlaubt") + + (rule.MaxQuantity is { } max ? $", höchstens {max} Stück" : "") + + (note is null ? "." : $" – {note}")); + } + + /// Alle gesetzten Sperren – für die Anzeige. + public async Task<(IReadOnlyList Locks, IReadOnlyList Rules)> + GetAllAsync(CancellationToken ct = default) + { + await using var db = await _dbf.CreateDbContextAsync(ct); + return (await db.PositionLocks.ToListAsync(ct), await db.InstrumentRules.ToListAsync(ct)); + } + + /// Verwirft den Zwischenspeicher – nach jeder Änderung, auch von außen. + public void Invalidate() + { + _locks = null; + _rules = null; + } + + // ─── Laden ─────────────────────────────────────────────────────────────────── + + private async Task EnsureLoadedAsync(CancellationToken ct) + { + if (_locks is not null && _rules is not null) return; + + await _reload.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_locks is not null && _rules is not null) return; + + await using var db = await _dbf.CreateDbContextAsync(ct); + + var locks = await db.PositionLocks.ToListAsync(ct); + var rules = await db.InstrumentRules.ToListAsync(ct); + + _locks = locks.ToDictionary( + x => $"{x.Book}|{x.ContractKey}", + x => Enum.TryParse(x.Level, ignoreCase: true, out var level) + ? level + // Ein unlesbarer Wert wird als schärfste Stufe gelesen, nicht als "frei". + : PositionLock.Locked, + StringComparer.OrdinalIgnoreCase); + + _rules = rules.ToDictionary( + x => x.ContractKey, + x => new InstrumentRule(x.Blocked, x.MaxQuantity), + StringComparer.OrdinalIgnoreCase); + + if (_loadFailed) + { + _logger.Info(LogModule, "Sperren wieder lesbar."); + _loadFailed = false; + } + } + catch (Exception ex) + { + // Nicht durchlassen: Der Aufrufer bekommt gleich die schärfste Antwort. Eine Sperre, + // die bei einem Datenbankfehler verschwindet, wäre keine. + if (!_loadFailed) + { + _logger.Error(LogModule, + $"Sperren nicht lesbar – bis auf Weiteres gilt gesperrt: {ex.Message}", ex); + _loadFailed = true; + } + } + finally + { + _reload.Release(); + } + } + + private static string Key(BookId book, InstrumentRef instrument) => + $"{book.Value}|{instrument.Key}"; +} diff --git a/src/IBKRTrader.Core/Capital/TradingRules.cs b/src/IBKRTrader.Core/Capital/TradingRules.cs new file mode 100644 index 0000000..27dd3bd --- /dev/null +++ b/src/IBKRTrader.Core/Capital/TradingRules.cs @@ -0,0 +1,133 @@ +using IBKRTrader.Core.Trading; + +namespace IBKRTrader.Core.Capital; + +/// +/// Wie weit ein Modul eine bestehende Position noch anfassen darf. Vom Betreiber gesetzt, nicht +/// von einer Strategie. +/// +public enum PositionLock +{ + /// Keine Einschränkung – die Vorgabe. + Free, + + /// Nur abbauen. Ein Modul darf verkaufen bzw. glattstellen, aber nicht aufstocken. + CloseOnly, + + /// Nur aufbauen. Das Gegenstück, etwa während eines geplanten Einstiegs. + OpenOnly, + + /// + /// Kein Modul fasst die Position an – weder öffnen noch schließen. + /// + /// Diese Stufe verhindert auch den Ausgang. Eine Position, die aus Risikogründen + /// geschlossen werden müsste, bleibt liegen, bis jemand die Sperre löst. Das ist eine zulässige + /// Entscheidung des Betreibers und muss beim Setzen sichtbar sein – nicht erst im + /// Störungsfall. + /// + Locked +} + +/// +/// Was für ein Instrument gilt, unabhängig von Buch und Bestand: ein Handelsverbot und/oder eine +/// harte Obergrenze in Stück. +/// +/// +/// Kein Modul eröffnet in diesem Instrument. Schließen bleibt erlaubt – ein nachträgliches Verbot +/// darf keine Position einsperren, die man gerade loswerden will. Wer auch das Schließen +/// unterbinden will, nimmt . +/// +/// +/// Höchstbestand über alle Bücher, einschließlich MANUAL; null = keine Grenze. +/// Bewusst etwas anderes als das prozentuale Konzentrationslimit: Der Prozentsatz wächst mit dem +/// Konto und ist Risikostreuung, die Stückzahl ist eine Aussage über ein bestimmtes Papier. Beide +/// gelten nebeneinander, es zieht die engere. +/// +public sealed record InstrumentRule(bool Blocked = false, int? MaxQuantity = null) +{ + /// Die Regel, die nichts einschränkt – für Instrumente ohne Eintrag. + public static readonly InstrumentRule None = new(); + + public bool IsRestrictive => Blocked || MaxQuantity is not null; +} + +/// +/// Die Sperren des Betreibers, als reine Rechnung – kein Zustand, keine Datenbank. +/// +/// Warum im Core und nicht im Modul. Eine Sperre, die ein Modul selbst prüfen müsste, +/// ist keine Sperre, sondern eine Bitte. Sie steht deshalb im Risikopfad neben der +/// Deckungsprüfung, wo ein Modulfehler sie nicht umgehen kann. +/// +/// Was sie nicht tut: Der manuelle Handel des Betreibers in TWS bleibt unberührt. +/// Gesperrt werden Module, nicht der Mensch. +/// +public static class TradingRules +{ + /// + /// Prüft eine geplante Order gegen die Positionssperre. null = erlaubt. + /// + /// Die gesetzte Stufe. + /// Betroffenes Instrument – nur für die Meldung. + /// Der eröffnende Teil des Auftrags (0 bei reinem Schließen). + /// Der schließende Teil. + public static LimitBreach? CheckPositionLock( + PositionLock lockLevel, InstrumentRef instrument, int openingQuantity, int closingQuantity) + { + switch (lockLevel) + { + case PositionLock.Free: + return null; + + case PositionLock.CloseOnly when openingQuantity <= 0: + case PositionLock.OpenOnly when closingQuantity <= 0: + return null; + + case PositionLock.CloseOnly: + // Der schließende Teil bleibt erlaubt; abgelehnt wird nur, was darüber hinausgeht. + return Breach(instrument, LimitKind.PositionLocked, maxQuantity: closingQuantity); + + case PositionLock.OpenOnly: + return Breach(instrument, LimitKind.PositionLocked, maxQuantity: openingQuantity); + + case PositionLock.Locked: + return Breach(instrument, LimitKind.PositionLocked, maxQuantity: 0); + + default: + return null; + } + } + + /// + /// Prüft eine geplante Order gegen die Regeln des Instruments. null = erlaubt. + /// + /// Regel des Instruments; für „keine". + /// Betroffenes Instrument. + /// Der eröffnende Teil – nur er wird begrenzt. + /// + /// Bestand über alle Bücher, als Betrag. Eine geschriebene Option zählt dabei mit ihrer Menge, + /// nicht mit ihrem Vorzeichen: „höchstens 100 Stück" meint die Größe der Verpflichtung. + /// + public static LimitBreach? CheckInstrumentRule( + InstrumentRule rule, InstrumentRef instrument, int openingQuantity, int heldTotal) + { + if (openingQuantity <= 0 || !rule.IsRestrictive) return null; + + // Ein Verbot sperrt das Eröffnen, nicht das Schließen: Der schließende Teil kommt hier gar + // nicht an, deshalb genügt der Blick auf openingQuantity. + if (rule.Blocked) + return Breach(instrument, LimitKind.InstrumentBlocked, maxQuantity: 0); + + if (rule.MaxQuantity is not { } max) return null; + + var held = Math.Abs(heldTotal); + if (held + openingQuantity <= max) return null; + + return new LimitBreach( + LimitKind.InstrumentQuantity, instrument.Key, + Current: held + openingQuantity, Limit: max, + MaxQuantity: Math.Max(0, max - held), MaxNotional: 0m); + } + + private static LimitBreach Breach(InstrumentRef instrument, LimitKind kind, int maxQuantity) => + new(kind, instrument.Key, Current: 0m, Limit: 0m, MaxQuantity: maxQuantity, MaxNotional: 0m); +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs index 8ed19df..12e2db1 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -21,6 +21,10 @@ public class CoreDbContext : DbContext // Kapital- und Buchmodell public DbSet Reservations => Set(); + // Sperren des Betreibers (#485/#486) + public DbSet PositionLocks => Set(); + public DbSet InstrumentRules => Set(); + // Datenfundament für Analyse/Forensik (Supervisor) public DbSet DecisionJournal => Set(); public DbSet OrderEvents => Set(); @@ -144,6 +148,24 @@ public class CoreDbContext : DbContext e.Property(x => x.Ticker).HasMaxLength(50); }); + b.Entity(e => + { + e.ToTable("core_position_lock"); + e.HasKey(x => new { x.Book, x.ContractKey }); + e.Property(x => x.Book).HasMaxLength(50); + e.Property(x => x.ContractKey).HasMaxLength(64); + e.Property(x => x.Level).HasMaxLength(16); + e.Property(x => x.Note).HasMaxLength(255); + }); + + b.Entity(e => + { + e.ToTable("core_instrument_rule"); + e.HasKey(x => x.ContractKey); + e.Property(x => x.ContractKey).HasMaxLength(64); + e.Property(x => x.Note).HasMaxLength(255); + }); + b.Entity(e => { e.ToTable("core_reservation"); diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260905120255_Sperren_Positionen_und_Instrumente.Designer.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260905120255_Sperren_Positionen_und_Instrumente.Designer.cs new file mode 100644 index 0000000..10550fa --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260905120255_Sperren_Positionen_und_Instrumente.Designer.cs @@ -0,0 +1,649 @@ +// +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("20260905120255_Sperren_Positionen_und_Instrumente")] + partial class Sperren_Positionen_und_Instrumente + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Ticker"); + + b.HasIndex("InstrumentId", "Source", "Ticker") + .IsUnique(); + + b.ToTable("core_ibkr_external_identifiers", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("CompanyName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Exchange") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IbkrConid") + .HasColumnType("bigint"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Isin") + .HasMaxLength(12) + .HasColumnType("varchar(12)"); + + b.Property("LastFetched") + .HasColumnType("datetime(6)"); + + b.Property("PrimaryExchange") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SecType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Sector") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Active"); + + b.HasIndex("IbkrConid") + .IsUnique(); + + b.HasIndex("Symbol"); + + b.ToTable("core_ibkr_instruments", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b => + { + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("BarSize") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("BarCount") + .HasColumnType("int"); + + b.Property("Close") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("High") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Low") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Open") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.Property("Wap") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.HasKey("InstrumentId", "BarSize", "Timestamp"); + + b.HasIndex("Timestamp"); + + b.ToTable("core_ibkr_market_data", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxPerTrade") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UsedBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Module"); + + b.ToTable("core_budget", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ContextJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SignalPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Module", "Timestamp"); + + b.ToTable("core_decision_journal", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreInstrumentRule", b => + { + b.Property("ContractKey") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Blocked") + .HasColumnType("tinyint(1)"); + + b.Property("MaxQuantity") + .HasColumnType("int"); + + b.Property("Note") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("ContractKey"); + + b.ToTable("core_instrument_rule", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Response") + .IsRequired() + .HasColumnType("text"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol", "Timestamp"); + + b.ToTable("core_order_events", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ContractKey") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("AvgPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Expiry") + .HasColumnType("date"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Multiplier") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Right") + .HasMaxLength(1) + .HasColumnType("varchar(1)"); + + b.Property("Strike") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Module", "ContractKey"); + + b.HasIndex("Symbol"); + + b.ToTable("core_position", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePositionLock", b => + { + b.Property("Book") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ContractKey") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + + b.Property("Note") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Book", "ContractKey"); + + b.ToTable("core_position_lock", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Book") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BrokerOrderId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ContractKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BrokerOrderId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("SignalId", "ContractKey") + .IsUnique(); + + b.HasIndex("State", "Book"); + + b.ToTable("core_reservation", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("core_settings", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IbkrOrderId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SignalId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TotalValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TradedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Module"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol"); + + b.ToTable("core_trade_history", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WorkerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("WorkerName", "StartedAt"); + + b.ToTable("core_worker_log", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260905120255_Sperren_Positionen_und_Instrumente.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260905120255_Sperren_Positionen_und_Instrumente.cs new file mode 100644 index 0000000..7f736e7 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260905120255_Sperren_Positionen_und_Instrumente.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class Sperren_Positionen_und_Instrumente : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "core_instrument_rule", + columns: table => new + { + ContractKey = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Blocked = table.Column(type: "tinyint(1)", nullable: false), + MaxQuantity = table.Column(type: "int", nullable: true), + Note = table.Column(type: "varchar(255)", maxLength: 255, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_core_instrument_rule", x => x.ContractKey); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "core_position_lock", + columns: table => new + { + Book = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ContractKey = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Level = table.Column(type: "varchar(16)", maxLength: 16, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Note = table.Column(type: "varchar(255)", maxLength: 255, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_core_position_lock", x => new { x.Book, x.ContractKey }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "core_instrument_rule"); + + migrationBuilder.DropTable( + name: "core_position_lock"); + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index 25a4103..901a79e 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -263,6 +263,30 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations b.ToTable("core_decision_journal", (string)null); }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreInstrumentRule", b => + { + b.Property("ContractKey") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Blocked") + .HasColumnType("tinyint(1)"); + + b.Property("MaxQuantity") + .HasColumnType("int"); + + b.Property("Note") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("ContractKey"); + + b.ToTable("core_instrument_rule", (string)null); + }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b => { b.Property("Id") @@ -384,6 +408,33 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations b.ToTable("core_position", (string)null); }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePositionLock", b => + { + b.Property("Book") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ContractKey") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + + b.Property("Note") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Book", "ContractKey"); + + b.ToTable("core_position_lock", (string)null); + }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b => { b.Property("Id") diff --git a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs index ae95343..8554343 100644 --- a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs +++ b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs @@ -95,3 +95,46 @@ public class CoreSetting public string? Value { get; set; } public DateTime UpdatedAt { get; set; } } + +/// +/// Sperre, die der Betreiber auf eine Position gesetzt hat (#485). Gilt je Buch und Instrument. +/// +/// Bewusst eine eigene Tabelle statt einer Spalte an : Eine Sperre +/// überdauert die Position. Wer eine Position schließt und später wieder aufbaut, soll nicht +/// stillschweigend eine gelöste Sperre vorfinden. +/// +public class CorePositionLock +{ + public string Book { get; set; } = ""; + public string ContractKey { get; set; } = ""; + + /// Stufe als Name des Enums PositionLock. + public string Level { get; set; } = "Free"; + + /// Warum gesperrt wurde – für den Menschen, der es später liest. + public string? Note { get; set; } + + public DateTime UpdatedAt { get; set; } +} + +/// +/// Regel, die der Betreiber für ein Instrument gesetzt hat (#486): Handelsverbot und/oder harte +/// Bestandsobergrenze. Gilt über alle Bücher. +/// +public class CoreInstrumentRule +{ + /// + /// Instrumentenschlüssel ("AAPL", "SAP:EUR") oder ein reines Symbol, das dann für alle + /// Notierungen gilt. + /// + public string ContractKey { get; set; } = ""; + + public bool Blocked { get; set; } + + /// Höchstbestand in Stück bzw. Kontrakten; null = keine Grenze. + public int? MaxQuantity { get; set; } + + public string? Note { get; set; } + + public DateTime UpdatedAt { get; set; } +} diff --git a/src/IBKRTrader.Core/Trading/RiskService.cs b/src/IBKRTrader.Core/Trading/RiskService.cs index f47e573..f388b54 100644 --- a/src/IBKRTrader.Core/Trading/RiskService.cs +++ b/src/IBKRTrader.Core/Trading/RiskService.cs @@ -24,9 +24,41 @@ public sealed class RiskService : IRiskService if (SlippageTooHigh(signal, context, risk, out var slipReason)) return RiskDecision.Reject(slipReason); - return signal.Side == TradeSide.Sell + var decision = signal.Side == TradeSide.Sell ? EvaluateSell(signal, context, risk) : EvaluateBuy(signal, context, risk); + + // Die Sperren des Betreibers zuletzt, aber über allem: Sie gelten auch für einen Auftrag, + // den jede andere Prüfung durchgewinkt hat. Erst hier steht fest, wie viel der Auftrag + // eröffnet und wie viel er schließt - und genau daran hängen die Stufen. + return decision.Approved ? ApplyOperatorRules(signal, decision, context) : decision; + } + + /// + /// Positionssperre und Instrumentenregeln (#485/#486). Sie stehen bewusst NACH der fachlichen + /// Bewertung: Erst dort ist bekannt, welcher Teil des Auftrags eröffnet und welcher schließt, + /// und die Stufen unterscheiden genau das. + /// + /// Und sie stehen bewusst im Core: Eine Sperre, die ein Modul selbst prüfen müsste, ist + /// keine Sperre. Ein Modulfehler kann sie hier nicht umgehen. + /// + private static RiskDecision ApplyOperatorRules( + TradeSignal signal, RiskDecision decision, RiskContext context) + { + var instrument = signal.Instrument; + var closing = decision.ClosingQuantity; + var opening = Math.Max(0, decision.Quantity - closing); + + if (TradingRules.CheckPositionLock(context.PositionLock, instrument, opening, closing) + is { } lockBreach) + return RiskDecision.Reject(lockBreach); + + if (TradingRules.CheckInstrumentRule( + context.InstrumentRule, instrument, opening, context.HeldTotalQuantity) + is { } ruleBreach) + return RiskDecision.Reject(ruleBreach); + + return decision; } // ─── Verkauf ───────────────────────────────────────────────────────────────── diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index 2daef30..c128da5 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -339,6 +339,23 @@ public sealed record RiskContext /// true, wenn der Watchdog Käufe global gesperrt hat (§4 Schicht D). public bool BuyingHalted { get; init; } + + /// + /// Sperrstufe, die der Betreiber für diese Position gesetzt hat (#485). Vorgabe: keine. + /// + public Capital.PositionLock PositionLock { get; init; } = Capital.PositionLock.Free; + + /// + /// Regeln, die der Betreiber für dieses Instrument gesetzt hat (#486) – Handelsverbot und + /// harte Bestandsobergrenze. Vorgabe: keine. + /// + public Capital.InstrumentRule InstrumentRule { get; init; } = Capital.InstrumentRule.None; + + /// + /// Bestand dieses Instruments über alle Bücher, MANUAL eingeschlossen – Bezugsgröße der + /// harten Obergrenze. Kauft der Betreiber von Hand 100 Stück, sind die Module dort gesperrt. + /// + public int HeldTotalQuantity { get; init; } } /// Aus den Settings abgeleitete Risiko-Parameter. diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs index e0d81d1..8ad8698 100644 --- a/src/IBKRTrader.Hosting/AppHostBuilder.cs +++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs @@ -252,6 +252,7 @@ public static class AppHostBuilder services.AddSingleton(_ => new BookRegistry( CreateModules().Select(BookNameOf).Append(BookId.Diagnostics.Value))); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/tests/IBKRTrader.Tests/Capital/TradingRulesTests.cs b/tests/IBKRTrader.Tests/Capital/TradingRulesTests.cs new file mode 100644 index 0000000..e805c2d --- /dev/null +++ b/tests/IBKRTrader.Tests/Capital/TradingRulesTests.cs @@ -0,0 +1,137 @@ +using FluentAssertions; +using IBKRTrader.Core.Capital; +using IBKRTrader.Core.Trading; + +namespace IBKRTrader.Tests.Capital; + +/// +/// Die Sperren des Betreibers (#485/#486) als reine Rechnung. Sie gelten für Module – der +/// manuelle Handel in TWS bleibt unberührt. +/// +[Trait("cat", "unit")] +public class TradingRulesTests +{ + private static readonly InstrumentRef Aapl = InstrumentRef.ForStock("AAPL"); + + // ─── Positionssperre ───────────────────────────────────────────────────────── + + [Fact] + public void Free_LetsEverythingThrough() + { + TradingRules.CheckPositionLock(PositionLock.Free, Aapl, openingQuantity: 10, closingQuantity: 0) + .Should().BeNull(); + } + + [Fact] + public void CloseOnly_AllowsReducing_ButNotBuilding() + { + // Reines Schließen: erlaubt. + TradingRules.CheckPositionLock(PositionLock.CloseOnly, Aapl, 0, 10).Should().BeNull(); + + // Aufstocken: abgelehnt. + var breach = TradingRules.CheckPositionLock(PositionLock.CloseOnly, Aapl, 10, 0); + breach.Should().NotBeNull(); + breach!.Kind.Should().Be(LimitKind.PositionLocked); + breach.MaxQuantity.Should().Be(0, "es gibt nichts zu schließen, also geht nichts"); + } + + [Fact] + public void CloseOnly_OnAMixedOrder_ReportsTheClosingPartAsStillPossible() + { + // Ein Auftrag, der 4 schließt und 6 neu aufmacht: der schließende Teil bleibt möglich. + var breach = TradingRules.CheckPositionLock(PositionLock.CloseOnly, Aapl, + openingQuantity: 6, closingQuantity: 4); + + breach.Should().NotBeNull(); + breach!.MaxQuantity.Should().Be(4, "so viel ginge jetzt noch – das kann das Modul nutzen"); + } + + [Fact] + public void OpenOnly_IsTheMirrorImage() + { + TradingRules.CheckPositionLock(PositionLock.OpenOnly, Aapl, 10, 0).Should().BeNull(); + + var breach = TradingRules.CheckPositionLock(PositionLock.OpenOnly, Aapl, 0, 10); + breach.Should().NotBeNull(); + breach!.Kind.Should().Be(LimitKind.PositionLocked); + } + + [Fact] + public void Locked_BlocksTheExitToo() + { + // Das ist die unbequeme, aber gewollte Eigenschaft dieser Stufe: Sie verhindert auch das + // Schließen. Wer sie setzt, muss das wissen - deshalb steht es hier als Test und nicht + // nur im Kommentar. + TradingRules.CheckPositionLock(PositionLock.Locked, Aapl, 10, 0).Should().NotBeNull(); + TradingRules.CheckPositionLock(PositionLock.Locked, Aapl, 0, 10).Should().NotBeNull(); + + TradingRules.CheckPositionLock(PositionLock.Locked, Aapl, 0, 10)! + .MaxQuantity.Should().Be(0); + } + + // ─── Instrumentenregeln ────────────────────────────────────────────────────── + + [Fact] + public void NoRule_LetsEverythingThrough() + { + TradingRules.CheckInstrumentRule(InstrumentRule.None, Aapl, 100, 0).Should().BeNull(); + } + + [Fact] + public void BlockedInstrument_CannotBeOpened() + { + var breach = TradingRules.CheckInstrumentRule(new InstrumentRule(Blocked: true), Aapl, 1, 0); + + breach.Should().NotBeNull(); + breach!.Kind.Should().Be(LimitKind.InstrumentBlocked); + breach.MaxQuantity.Should().Be(0); + } + + [Fact] + public void BlockedInstrument_CanStillBeClosed() + { + // Der schließende Teil kommt hier gar nicht an - geprüft wird nur, was eröffnet. + // Ein nachträgliches Verbot darf keine Position einsperren, die man loswerden will. + TradingRules.CheckInstrumentRule(new InstrumentRule(Blocked: true), Aapl, + openingQuantity: 0, heldTotal: 100).Should().BeNull(); + } + + [Fact] + public void MaxQuantity_CountsWhatIsAlreadyHeld() + { + var rule = new InstrumentRule(MaxQuantity: 100); + + // 60 im Bestand, 40 dazu: genau an der Grenze, erlaubt. + TradingRules.CheckInstrumentRule(rule, Aapl, openingQuantity: 40, heldTotal: 60) + .Should().BeNull(); + + // 60 im Bestand, 50 dazu: darüber. + var breach = TradingRules.CheckInstrumentRule(rule, Aapl, openingQuantity: 50, heldTotal: 60); + breach.Should().NotBeNull(); + breach!.Kind.Should().Be(LimitKind.InstrumentQuantity); + breach.Current.Should().Be(110m); + breach.Limit.Should().Be(100m); + breach.MaxQuantity.Should().Be(40, "so viele gingen noch"); + } + + [Fact] + public void MaxQuantity_CountsShortPositionsByTheirSize() + { + // Eine geschriebene Option steht mit negativem Vorzeichen im Buch. "Höchstens 5 Kontrakte" + // meint die Größe der Verpflichtung, nicht ihre Richtung. + var rule = new InstrumentRule(MaxQuantity: 5); + + TradingRules.CheckInstrumentRule(rule, Aapl, openingQuantity: 2, heldTotal: -4) + .Should().NotBeNull("4 geschriebene plus 2 weitere sind 6"); + } + + [Fact] + public void AlreadyOverTheLimit_LeavesNoRoom() + { + var rule = new InstrumentRule(MaxQuantity: 100); + var breach = TradingRules.CheckInstrumentRule(rule, Aapl, openingQuantity: 1, heldTotal: 150); + + breach.Should().NotBeNull(); + breach!.MaxQuantity.Should().Be(0, "über der Grenze gibt es keinen Rest, auch keinen negativen"); + } +}