Beim ersten erfolgreichen Order-Nachweis (#293) gegen das Paper-Konto lief die Order auf die falsche Notierung: geprueft, bepreist und reserviert wurde SAP:EUR (XETRA, 188,74 EUR), ausgefuehrt wurde die SAP-ADR in New York zu 216,68 USD. Das USD-Guthaben rutschte dadurch von -3.751 auf -4.185 - also genau der Margin-Kredit, den Schicht C verhindern soll, aufgenommen an der Sicherung vorbei. Ursache: ExecutionService baute die OrderRequest aus Einzelfeldern und setzte Currency nicht; die Eigenschaft fiel still auf USD zurueck. Es gab keine Fehlermeldung, und die Buecher wiesen anschliessend eine EUR-Position aus, die es beim Broker nie gab. Behebung nicht durch Nachtragen der Zuweisung, sondern baulich: OrderRequest.For(instrument, ...) uebertraegt die Identitaet vollstaendig. Wer die Felder einzeln setzt, baut ein zweites, halb ausgefuelltes Instrument - das darf im Orderpfad nicht mehr moeglich sein. Zwei Regressionstests, die genau diesen Fall fangen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
302 lines
13 KiB
C#
302 lines
13 KiB
C#
using FluentAssertions;
|
||
using IBKRTrader.Core.Capital;
|
||
using IBKRTrader.Core.Logging;
|
||
using IBKRTrader.Core.Persistence;
|
||
using IBKRTrader.Core.Persistence.Entities;
|
||
using IBKRTrader.Core.Settings;
|
||
using IBKRTrader.Core.Trading;
|
||
using NSubstitute;
|
||
|
||
namespace IBKRTrader.Tests.Trading;
|
||
|
||
[Trait("cat", "unit")]
|
||
public class ExecutionServiceTests
|
||
{
|
||
private readonly IBrokerClient _broker = Substitute.For<IBrokerClient>();
|
||
private readonly IRiskService _risk = Substitute.For<IRiskService>();
|
||
private readonly IPortfolioService _portfolio = Substitute.For<IPortfolioService>();
|
||
private readonly SettingsService _settings = new();
|
||
private readonly IDecisionJournal _journal = Substitute.For<IDecisionJournal>();
|
||
private readonly IOrderEventLog _orderLog = Substitute.For<IOrderEventLog>();
|
||
private readonly ICapitalService _capital = Substitute.For<ICapitalService>();
|
||
|
||
private static readonly BookId Book = BookId.ForModule("CT");
|
||
|
||
private ExecutionService CreateSut() =>
|
||
new(_broker, _risk, _portfolio, _settings, new LoggingService(), _journal, _orderLog, _capital);
|
||
|
||
private static readonly TradeSignal BuySignal = new()
|
||
{
|
||
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT"
|
||
};
|
||
|
||
private void EnableTrading() => _settings.Settings.Trading.TradingEnabled = true;
|
||
|
||
/// <summary>
|
||
/// Kapital-Seam im Normalfall: Buch bekannt, Kontext leer, Bindung geht durch. Die Regeln des
|
||
/// Kapitalmodells werden einzeln in CapitalTests geprueft – hier geht es nur darum, dass der
|
||
/// Ausfuehrungspfad sie an der richtigen Stelle aufruft.
|
||
/// </summary>
|
||
private void ArrangeCapital(long reservationId = 1)
|
||
{
|
||
_capital.ResolveBook("CT").Returns(Book);
|
||
_capital.BuildContextAsync(Arg.Any<BookId>(), Arg.Any<InstrumentRef>(), Arg.Any<decimal>(),
|
||
Arg.Any<AccountState>(), Arg.Any<CancellationToken>())
|
||
.Returns(ci => Task.FromResult(new RiskContext
|
||
{
|
||
Price = ci.ArgAt<decimal>(2),
|
||
NetLiquidation = ci.ArgAt<AccountState>(3).NetLiquidation
|
||
}));
|
||
_capital.ReserveAsync(Arg.Any<BookId>(), Arg.Any<TradeSignal>(), Arg.Any<InstrumentRef>(),
|
||
Arg.Any<TradeSide>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<decimal>(),
|
||
Arg.Any<OrderType>(), Arg.Any<AccountState>(), Arg.Any<string>(),
|
||
Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new ReservationService.ReserveResult(
|
||
new CoreReservation { Id = reservationId }, null, false)));
|
||
}
|
||
|
||
private void ArrangeHappyPath()
|
||
{
|
||
EnableTrading();
|
||
_broker.GetQuoteAsync(Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
|
||
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
|
||
_portfolio.GetModuleExposureAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(0m));
|
||
_portfolio.GetPositionQuantityAsync(Arg.Any<string>(), Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(0));
|
||
ArrangeCapital();
|
||
_risk.Evaluate(Arg.Any<TradeSignal>(), Arg.Any<RiskContext>(), Arg.Any<RiskParameters>())
|
||
.Returns(RiskDecision.Approve(5));
|
||
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(OrderResult.Filled("O1", 5, 100m)));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TradingDisabled_Skips_WithoutTouchingBroker()
|
||
{
|
||
// TradingEnabled ist standardmäßig false
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("SKIP");
|
||
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task NoQuote_Skips()
|
||
{
|
||
EnableTrading();
|
||
_broker.GetQuoteAsync(Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<Quote?>(null));
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("SKIP");
|
||
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RiskRejects_Skips_WithReason()
|
||
{
|
||
EnableTrading();
|
||
_broker.GetQuoteAsync(Arg.Any<InstrumentRef>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
|
||
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
|
||
ArrangeCapital();
|
||
_risk.Evaluate(Arg.Any<TradeSignal>(), Arg.Any<RiskContext>(), Arg.Any<RiskParameters>())
|
||
.Returns(RiskDecision.Reject("Modul-Limit überschritten."));
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("SKIP");
|
||
result.Reason.Should().Contain("Modul-Limit");
|
||
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task HappyPath_PlacesOrder_AndBooksFill()
|
||
{
|
||
ArrangeHappyPath();
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Executed.Should().BeTrue();
|
||
result.Order!.OrderId.Should().Be("O1");
|
||
await _portfolio.Received(1).RecordFillAsync(
|
||
"CT", InstrumentRef.ForStock("AAPL"), TradeSide.Buy, 5, 100m, "O1", Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task HappyPath_MarketOrder_WhenNoLimitPrice()
|
||
{
|
||
ArrangeHappyPath();
|
||
|
||
await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
await _broker.Received(1).PlaceOrderAsync(
|
||
Arg.Is<OrderRequest>(o => o.Type == OrderType.Market && o.Quantity == 5),
|
||
Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TradingDisabled_WritesSkippedDecision()
|
||
{
|
||
await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
_journal.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord>(
|
||
d => d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Skipped &&
|
||
d.Reason == IBKRTrader.Core.Persistence.Entities.DecisionReason.TradingDisabled));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task HappyPath_PropagatesSignalId_AndJournalsExecuted()
|
||
{
|
||
ArrangeHappyPath();
|
||
var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT", SignalId = "sig-abc" };
|
||
|
||
await CreateSut().ExecuteAsync(signal);
|
||
|
||
await _portfolio.Received(1).RecordFillAsync(
|
||
"CT", InstrumentRef.ForStock("AAPL"), TradeSide.Buy, 5, 100m, "O1", "sig-abc", Arg.Any<CancellationToken>());
|
||
_journal.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord>(
|
||
d => d.SignalId == "sig-abc" &&
|
||
d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Executed));
|
||
_orderLog.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreOrderEvent>(
|
||
e => e.SignalId == "sig-abc" &&
|
||
e.EventType == IBKRTrader.Core.Persistence.Entities.OrderEventType.Filled));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task UnknownBook_IsRejected_BeforeAnyOrder()
|
||
{
|
||
// SourceModule kommt aus dem Modul und ist kein Ausweis: ein nicht registrierter Name
|
||
// wuerde ein Buch ohne Cap erzeugen - also ein unbegrenztes.
|
||
ArrangeHappyPath();
|
||
_capital.ResolveBook("CT").Returns((BookId?)null);
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("SKIP");
|
||
result.Reason.Should().Contain("Unbekanntes Buch");
|
||
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CapitalRejects_NoOrderIsPlaced()
|
||
{
|
||
ArrangeHappyPath();
|
||
_capital.ReserveAsync(Arg.Any<BookId>(), Arg.Any<TradeSignal>(), Arg.Any<InstrumentRef>(),
|
||
Arg.Any<TradeSide>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<decimal>(),
|
||
Arg.Any<OrderType>(), Arg.Any<AccountState>(), Arg.Any<string>(),
|
||
Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new ReservationService.ReserveResult(
|
||
null,
|
||
new LimitBreach(LimitKind.CashAvailable, "AAPL", 5000m, 1000m, 10, 1000m),
|
||
false)));
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("SKIP");
|
||
result.Reason.Should().Contain("Freies Bargeld");
|
||
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExistingReservationForTheSameSignal_DoesNotPlaceASecondOrder()
|
||
{
|
||
// Der Fall, gegen den die Idempotenz gebaut ist: ein Reconnect schickt dasselbe Signal
|
||
// erneut. Ohne diese Sperre laege dieselbe Order zweimal beim Broker.
|
||
ArrangeHappyPath();
|
||
_capital.ReserveAsync(Arg.Any<BookId>(), Arg.Any<TradeSignal>(), Arg.Any<InstrumentRef>(),
|
||
Arg.Any<TradeSide>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<decimal>(),
|
||
Arg.Any<OrderType>(), Arg.Any<AccountState>(), Arg.Any<string>(),
|
||
Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new ReservationService.ReserveResult(
|
||
new CoreReservation { Id = 7 }, null, WasExisting: true)));
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("SKIP");
|
||
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task FailedOrder_ReleasesTheReservation()
|
||
{
|
||
// Sonst bliebe das Geld bis zum Fristablauf gebunden, obwohl gar keine Order existiert.
|
||
ArrangeHappyPath();
|
||
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(OrderResult.Fail("Broker abgelehnt")));
|
||
|
||
await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
await _capital.Received(1).CancelReservationAsync(1, Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task FilledOrder_SettlesTheReservation()
|
||
{
|
||
ArrangeHappyPath();
|
||
|
||
await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
// Reserviert wurde mit Puffer, ausgefuehrt zum echten Preis: 5 x 100 = 500 fliessen als
|
||
// tatsaechlicher Bedarf zurueck, der Rest wird freigegeben.
|
||
await _capital.Received(1).SettleReservationAsync(1, 5, 500m, Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TheOrderCarriesTheSignalsCurrency_NotTheDefault()
|
||
{
|
||
// Am 2026-09-04 gegen das Paper-Konto beobachtet: geprueft und reserviert wurde SAP:EUR,
|
||
// gehandelt wurde die SAP-ADR in New York, weil die OrderRequest aus Einzelfeldern gebaut
|
||
// wurde und die Waehrung dabei auf USD zurueckfiel. Ohne Fehlermeldung.
|
||
ArrangeHappyPath();
|
||
var signal = new TradeSignal
|
||
{
|
||
Symbol = "SAP", Side = TradeSide.Buy, SourceModule = "CT", Currency = "EUR"
|
||
};
|
||
|
||
await CreateSut().ExecuteAsync(signal);
|
||
|
||
await _broker.Received(1).PlaceOrderAsync(
|
||
Arg.Is<OrderRequest>(o => o.Currency == "EUR" && o.Instrument.Key == "SAP:EUR"),
|
||
Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TheOrderCarriesTheOptionContract()
|
||
{
|
||
ArrangeHappyPath();
|
||
var spec = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put);
|
||
var signal = new TradeSignal
|
||
{
|
||
Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT", Option = spec
|
||
};
|
||
|
||
await CreateSut().ExecuteAsync(signal);
|
||
|
||
await _broker.Received(1).PlaceOrderAsync(
|
||
Arg.Is<OrderRequest>(o => o.Option == spec && o.Instrument.Kind == InstrumentKind.Option),
|
||
Arg.Any<CancellationToken>());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task OrderFails_ReturnsError_AndDoesNotBook()
|
||
{
|
||
ArrangeHappyPath();
|
||
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(OrderResult.Fail("Broker abgelehnt")));
|
||
|
||
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||
|
||
result.Action.Should().Be("ERROR");
|
||
result.Reason.Should().Contain("Broker abgelehnt");
|
||
await _portfolio.DidNotReceive().RecordFillAsync(
|
||
Arg.Any<string>(), Arg.Any<InstrumentRef>(), Arg.Any<TradeSide>(),
|
||
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||
}
|
||
}
|