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");
+ }
+}