Files
IBKRTrader/tests/IBKRTrader.Tests/Capital/TradingRulesTests.cs
T
RichardandClaude Opus 5 27b594ca8f 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>
2026-09-05 14:06:18 +02:00

138 lines
5.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}