Sperren des Betreibers: Positionen und Instrumente, im Core durchgesetzt

Vier Stufen je Position (frei, nur schliessen, nur aufbauen, gesperrt) und je
Instrument ein Handelsverbot samt harter Bestandsobergrenze. Beides gilt fuer
Module; der manuelle Handel in TWS bleibt unberuehrt.

Die Pruefung sitzt im RiskService, nicht im Modul: Eine Sperre, die ein Modul
selbst pruefen muesste, ist keine Sperre, sondern eine Bitte. Sie laeuft NACH der
fachlichen Bewertung, weil erst dort feststeht, welcher Teil eines Auftrags
eroeffnet und welcher schliesst - genau daran haengen die Stufen.

Drei Entscheidungen, die im Betrieb zaehlen:

"Gesperrt" verhindert auch den Ausgang. Eine Position, die aus Risikogruenden
geschlossen werden muesste, bleibt liegen, bis jemand die Sperre loest. Das ist
gewollt und steht als eigener Test da, damit es niemand fuer einen Fehler haelt.

Ein Handelsverbot sperrt das Eroeffnen, nicht das Schliessen. Ein nachtraeglich
gesetztes Verbot darf keine Position einsperren, die man gerade loswerden will;
wer auch das will, nimmt die Positionssperre.

Die harte Stueckzahl zaehlt den Bestand ueber ALLE Buecher, MANUAL eingeschlossen,
und Short-Positionen mit ihrer Groesse statt ihrem Vorzeichen: "hoechstens 100"
meint die Groesse der Verpflichtung. Sie ersetzt das prozentuale
Konzentrationslimit nicht - der Prozentsatz waechst mit dem Konto, die Stueckzahl
ist eine Aussage ueber ein bestimmtes Papier. Es zieht die engere Grenze.

TradingRuleService haelt die Regeln im Speicher, weil sie bei jeder Risikopruefung
gebraucht werden, sich aber selten aendern. Laesst sich die Liste NICHT laden,
gilt das nicht als "keine Sperren": der Dienst meldet es und sperrt, bis sie
wieder lesbar ist. Eine Sperre, die bei einem Datenbankfehler verschwindet, waere
keine. Ein unlesbarer Stufenwert wird ebenso als schaerfste Stufe gelesen.

Auf dem Testserver angewendet (Tabellen core_position_lock und
core_instrument_rule) und der Dienst damit neu gestartet - keine Ladefehler, der
Handel bleibt frei. Zur Probe stehen dort die beiden Beispiele des Betreibers:
WIRECARD gesperrt, NVDA hoechstens 100 Stueck.

425 Tests gruen, 11 davon neu.

Arbeit an #485 und #486; die Oberflaeche fehlt noch, die Durchsetzung steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-05 14:06:18 +02:00
co-authored by Claude Opus 5
parent 0feeeba815
commit 27b594ca8f
13 changed files with 1396 additions and 2 deletions
+13 -1
View File
@@ -21,7 +21,19 @@ public enum LimitKind
Coverage, Coverage,
/// <summary>Der Watchdog hat den Kauf global gesperrt (§4 Schicht D).</summary> /// <summary>Der Watchdog hat den Kauf global gesperrt (§4 Schicht D).</summary>
BuyingHalted BuyingHalted,
/// <summary>
/// Der Betreiber hat diese Position gesperrt (#485). Je nach Stufe betrifft das nur das
/// Aufstocken, nur das Abbauen oder beides.
/// </summary>
PositionLocked,
/// <summary>Der Betreiber hat dieses Instrument vom Handel ausgeschlossen (#486).</summary>
InstrumentBlocked,
/// <summary>Die harte Bestandsobergrenze des Instruments ist erreicht (#486).</summary>
InstrumentQuantity
} }
/// <summary> /// <summary>
@@ -19,6 +19,7 @@ public sealed class CapitalService : ICapitalService
private readonly IPortfolioService _portfolio; private readonly IPortfolioService _portfolio;
private readonly ReservationService _reservations; private readonly ReservationService _reservations;
private readonly BookRegistry _books; private readonly BookRegistry _books;
private readonly TradingRuleService _rules;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly LoggingService _logger; private readonly LoggingService _logger;
@@ -26,9 +27,11 @@ public sealed class CapitalService : ICapitalService
IPortfolioService portfolio, IPortfolioService portfolio,
ReservationService reservations, ReservationService reservations,
BookRegistry books, BookRegistry books,
TradingRuleService rules,
SettingsService settings, SettingsService settings,
LoggingService logger) LoggingService logger)
{ {
_rules = rules;
_portfolio = portfolio; _portfolio = portfolio;
_reservations = reservations; _reservations = reservations;
_books = books; _books = books;
@@ -181,6 +184,13 @@ public sealed class CapitalService : ICapitalService
var held = await _portfolio var held = await _portfolio
.GetPositionQuantityAsync(book.Value, instrument, ct).ConfigureAwait(false); .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 return new RiskContext
{ {
Price = price, Price = price,
@@ -195,6 +205,9 @@ public sealed class CapitalService : ICapitalService
// Auch ein Kaufstopp, solange die Umrechnung unvollständig ist: Cap, Spielraum und // Auch ein Kaufstopp, solange die Umrechnung unvollständig ist: Cap, Spielraum und
// Konzentration stehen dann auf Zahlen, deren Groesse wir nicht kennen. Verkaufen und // Konzentration stehen dann auf Zahlen, deren Groesse wir nicht kennen. Verkaufen und
// Schliessen bleiben moeglich - angehalten wird nur das Aufmachen. // Schliessen bleiben moeglich - angehalten wird nur das Aufmachen.
PositionLock = positionLock,
InstrumentRule = instrumentRule,
HeldTotalQuantity = heldTotal,
BuyingHalted = MarginGuard.BuyingShouldHalt( BuyingHalted = MarginGuard.BuyingShouldHalt(
account, (decimal)Config.WatchdogMinCashPercent) account, (decimal)Config.WatchdogMinCashPercent)
|| snapshot.FxIncomplete || snapshot.FxIncomplete
@@ -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;
/// <summary>
/// Verwaltet die Sperren des Betreibers (#485/#486) und beantwortet sie im Risikopfad.
///
/// <para><b>Warum mit Zwischenspeicher.</b> Die Regeln werden bei <b>jeder</b> 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
/// <b>vollständig</b> geladen: eine halb geladene Sperrliste ist gefährlicher als keine, weil sie
/// aussieht, als sei alles geprüft.</para>
///
/// <para><b>Fehlerverhalten.</b> 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.</para>
/// </summary>
public sealed class TradingRuleService
{
private const string LogModule = "Kapital";
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
private readonly SemaphoreSlim _reload = new(1, 1);
private Dictionary<string, PositionLock>? _locks;
private Dictionary<string, InstrumentRule>? _rules;
private bool _loadFailed;
public TradingRuleService(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
{
_dbf = dbf;
_logger = logger;
}
/// <summary>Sperrstufe einer Position; <see cref="PositionLock.Free"/>, wenn keine gesetzt ist.</summary>
public async Task<PositionLock> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<InstrumentRule> 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 ──────────────────────────────────────────────────────────────────
/// <summary>Setzt oder löst die Sperre einer Position. <see cref="PositionLock.Free"/> löscht sie.</summary>
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."
: ""));
}
/// <summary>Setzt oder löscht die Regel eines Instruments.</summary>
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}"));
}
/// <summary>Alle gesetzten Sperren – für die Anzeige.</summary>
public async Task<(IReadOnlyList<CorePositionLock> Locks, IReadOnlyList<CoreInstrumentRule> Rules)>
GetAllAsync(CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
return (await db.PositionLocks.ToListAsync(ct), await db.InstrumentRules.ToListAsync(ct));
}
/// <summary>Verwirft den Zwischenspeicher – nach jeder Änderung, auch von außen.</summary>
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<PositionLock>(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}";
}
+133
View File
@@ -0,0 +1,133 @@
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Core.Capital;
/// <summary>
/// Wie weit ein Modul eine bestehende Position noch anfassen darf. Vom Betreiber gesetzt, nicht
/// von einer Strategie.
/// </summary>
public enum PositionLock
{
/// <summary>Keine Einschränkung – die Vorgabe.</summary>
Free,
/// <summary>Nur abbauen. Ein Modul darf verkaufen bzw. glattstellen, aber nicht aufstocken.</summary>
CloseOnly,
/// <summary>Nur aufbauen. Das Gegenstück, etwa während eines geplanten Einstiegs.</summary>
OpenOnly,
/// <summary>
/// Kein Modul fasst die Position an – weder öffnen noch schließen.
///
/// <para><b>Diese Stufe verhindert auch den Ausgang.</b> 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.</para>
/// </summary>
Locked
}
/// <summary>
/// Was für ein Instrument gilt, unabhängig von Buch und Bestand: ein Handelsverbot und/oder eine
/// harte Obergrenze in Stück.
/// </summary>
/// <param name="Blocked">
/// 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 <see cref="PositionLock.Locked"/>.
/// </param>
/// <param name="MaxQuantity">
/// Höchstbestand über <b>alle</b> Bücher, einschließlich MANUAL; <c>null</c> = 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.
/// </param>
public sealed record InstrumentRule(bool Blocked = false, int? MaxQuantity = null)
{
/// <summary>Die Regel, die nichts einschränkt – für Instrumente ohne Eintrag.</summary>
public static readonly InstrumentRule None = new();
public bool IsRestrictive => Blocked || MaxQuantity is not null;
}
/// <summary>
/// Die Sperren des Betreibers, als reine Rechnung – kein Zustand, keine Datenbank.
///
/// <para><b>Warum im Core und nicht im Modul.</b> 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.</para>
///
/// <para><b>Was sie nicht tut:</b> Der manuelle Handel des Betreibers in TWS bleibt unberührt.
/// Gesperrt werden Module, nicht der Mensch.</para>
/// </summary>
public static class TradingRules
{
/// <summary>
/// Prüft eine geplante Order gegen die Positionssperre. <c>null</c> = erlaubt.
/// </summary>
/// <param name="lockLevel">Die gesetzte Stufe.</param>
/// <param name="instrument">Betroffenes Instrument – nur für die Meldung.</param>
/// <param name="openingQuantity">Der eröffnende Teil des Auftrags (0 bei reinem Schließen).</param>
/// <param name="closingQuantity">Der schließende Teil.</param>
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;
}
}
/// <summary>
/// Prüft eine geplante Order gegen die Regeln des Instruments. <c>null</c> = erlaubt.
/// </summary>
/// <param name="rule">Regel des Instruments; <see cref="InstrumentRule.None"/> für „keine".</param>
/// <param name="instrument">Betroffenes Instrument.</param>
/// <param name="openingQuantity">Der eröffnende Teil – nur er wird begrenzt.</param>
/// <param name="heldTotal">
/// 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.
/// </param>
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);
}
@@ -21,6 +21,10 @@ public class CoreDbContext : DbContext
// Kapital- und Buchmodell // Kapital- und Buchmodell
public DbSet<CoreReservation> Reservations => Set<CoreReservation>(); public DbSet<CoreReservation> Reservations => Set<CoreReservation>();
// Sperren des Betreibers (#485/#486)
public DbSet<CorePositionLock> PositionLocks => Set<CorePositionLock>();
public DbSet<CoreInstrumentRule> InstrumentRules => Set<CoreInstrumentRule>();
// Datenfundament für Analyse/Forensik (Supervisor) // Datenfundament für Analyse/Forensik (Supervisor)
public DbSet<CoreDecisionRecord> DecisionJournal => Set<CoreDecisionRecord>(); public DbSet<CoreDecisionRecord> DecisionJournal => Set<CoreDecisionRecord>();
public DbSet<CoreOrderEvent> OrderEvents => Set<CoreOrderEvent>(); public DbSet<CoreOrderEvent> OrderEvents => Set<CoreOrderEvent>();
@@ -144,6 +148,24 @@ public class CoreDbContext : DbContext
e.Property(x => x.Ticker).HasMaxLength(50); e.Property(x => x.Ticker).HasMaxLength(50);
}); });
b.Entity<CorePositionLock>(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<CoreInstrumentRule>(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<CoreReservation>(e => b.Entity<CoreReservation>(e =>
{ {
e.ToTable("core_reservation"); e.ToTable("core_reservation");
@@ -0,0 +1,649 @@
// <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("20260905120255_Sperren_Positionen_und_Instrumente")]
partial class Sperren_Positionen_und_Instrumente
{
/// <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.CoreInstrumentRule", b =>
{
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<bool>("Blocked")
.HasColumnType("tinyint(1)");
b.Property<int?>("MaxQuantity")
.HasColumnType("int");
b.Property<string>("Note")
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("ContractKey");
b.ToTable("core_instrument_rule", (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.CorePositionLock", b =>
{
b.Property<string>("Book")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<string>("Note")
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTime>("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<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,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class Sperren_Positionen_und_Instrumente : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "core_instrument_rule",
columns: table => new
{
ContractKey = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Blocked = table.Column<bool>(type: "tinyint(1)", nullable: false),
MaxQuantity = table.Column<int>(type: "int", nullable: true),
Note = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(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<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContractKey = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Level = table.Column<string>(type: "varchar(16)", maxLength: 16, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Note = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_core_position_lock", x => new { x.Book, x.ContractKey });
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "core_instrument_rule");
migrationBuilder.DropTable(
name: "core_position_lock");
}
}
}
@@ -263,6 +263,30 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_decision_journal", (string)null); b.ToTable("core_decision_journal", (string)null);
}); });
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreInstrumentRule", b =>
{
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<bool>("Blocked")
.HasColumnType("tinyint(1)");
b.Property<int?>("MaxQuantity")
.HasColumnType("int");
b.Property<string>("Note")
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("ContractKey");
b.ToTable("core_instrument_rule", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b => modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -384,6 +408,33 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_position", (string)null); b.ToTable("core_position", (string)null);
}); });
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePositionLock", b =>
{
b.Property<string>("Book")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<string>("Note")
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Book", "ContractKey");
b.ToTable("core_position_lock", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b => modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -95,3 +95,46 @@ public class CoreSetting
public string? Value { get; set; } public string? Value { get; set; }
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
} }
/// <summary>
/// Sperre, die der Betreiber auf eine Position gesetzt hat (#485). Gilt je Buch und Instrument.
///
/// <para>Bewusst eine eigene Tabelle statt einer Spalte an <see cref="CorePosition"/>: Eine Sperre
/// überdauert die Position. Wer eine Position schließt und später wieder aufbaut, soll nicht
/// stillschweigend eine gelöste Sperre vorfinden.</para>
/// </summary>
public class CorePositionLock
{
public string Book { get; set; } = "";
public string ContractKey { get; set; } = "";
/// <summary>Stufe als Name des Enums <c>PositionLock</c>.</summary>
public string Level { get; set; } = "Free";
/// <summary>Warum gesperrt wurde – für den Menschen, der es später liest.</summary>
public string? Note { get; set; }
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// Regel, die der Betreiber für ein Instrument gesetzt hat (#486): Handelsverbot und/oder harte
/// Bestandsobergrenze. Gilt über alle Bücher.
/// </summary>
public class CoreInstrumentRule
{
/// <summary>
/// Instrumentenschlüssel ("AAPL", "SAP:EUR") oder ein reines Symbol, das dann für alle
/// Notierungen gilt.
/// </summary>
public string ContractKey { get; set; } = "";
public bool Blocked { get; set; }
/// <summary>Höchstbestand in Stück bzw. Kontrakten; null = keine Grenze.</summary>
public int? MaxQuantity { get; set; }
public string? Note { get; set; }
public DateTime UpdatedAt { get; set; }
}
+33 -1
View File
@@ -24,9 +24,41 @@ public sealed class RiskService : IRiskService
if (SlippageTooHigh(signal, context, risk, out var slipReason)) if (SlippageTooHigh(signal, context, risk, out var slipReason))
return RiskDecision.Reject(slipReason); return RiskDecision.Reject(slipReason);
return signal.Side == TradeSide.Sell var decision = signal.Side == TradeSide.Sell
? EvaluateSell(signal, context, risk) ? EvaluateSell(signal, context, risk)
: EvaluateBuy(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;
}
/// <summary>
/// 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.
///
/// <para>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.</para>
/// </summary>
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 ───────────────────────────────────────────────────────────────── // ─── Verkauf ─────────────────────────────────────────────────────────────────
@@ -339,6 +339,23 @@ public sealed record RiskContext
/// <summary>true, wenn der Watchdog Käufe global gesperrt hat (§4 Schicht D).</summary> /// <summary>true, wenn der Watchdog Käufe global gesperrt hat (§4 Schicht D).</summary>
public bool BuyingHalted { get; init; } public bool BuyingHalted { get; init; }
/// <summary>
/// Sperrstufe, die der Betreiber für diese Position gesetzt hat (#485). Vorgabe: keine.
/// </summary>
public Capital.PositionLock PositionLock { get; init; } = Capital.PositionLock.Free;
/// <summary>
/// Regeln, die der Betreiber für dieses Instrument gesetzt hat (#486) – Handelsverbot und
/// harte Bestandsobergrenze. Vorgabe: keine.
/// </summary>
public Capital.InstrumentRule InstrumentRule { get; init; } = Capital.InstrumentRule.None;
/// <summary>
/// Bestand dieses Instruments über <b>alle</b> Bücher, MANUAL eingeschlossen – Bezugsgröße der
/// harten Obergrenze. Kauft der Betreiber von Hand 100 Stück, sind die Module dort gesperrt.
/// </summary>
public int HeldTotalQuantity { get; init; }
} }
/// <summary>Aus den Settings abgeleitete Risiko-Parameter.</summary> /// <summary>Aus den Settings abgeleitete Risiko-Parameter.</summary>
+1
View File
@@ -252,6 +252,7 @@ public static class AppHostBuilder
services.AddSingleton(_ => new BookRegistry( services.AddSingleton(_ => new BookRegistry(
CreateModules().Select(BookNameOf).Append(BookId.Diagnostics.Value))); CreateModules().Select(BookNameOf).Append(BookId.Diagnostics.Value)));
services.AddSingleton<ReservationService>(); services.AddSingleton<ReservationService>();
services.AddSingleton<TradingRuleService>();
services.AddSingleton<CapitalService>(); services.AddSingleton<CapitalService>();
services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<CapitalService>()); services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<CapitalService>());
@@ -0,0 +1,137 @@
using FluentAssertions;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Capital;
/// <summary>
/// Die Sperren des Betreibers (#485/#486) als reine Rechnung. Sie gelten für Module – der
/// manuelle Handel in TWS bleibt unberührt.
/// </summary>
[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");
}
}