- Einheitlicher WebserverWorker in IBKRTrader.Hosting auf Kestrel-Basis - Token-Authentifizierung (AuthService) mit zeitkonstantem Vergleich und HMAC-Session-Tokens - OrderManager im Core fuer Bestands- und Reservierungsabgleich offener Broker-Orders - SettingsSchema fuer reflexionsbasiertes Auslesen und sichere Aktualisierung ohne Klartext-Passwoerter - SSE-Streaming (LogStreamService) fuer Echtzeit-Logs ueber isolierte Channels - Statische Single-Page-Application in wwwroot (Avalonia Fluent / Polytrader Theme) fuer alle 9 Ansichten - Unit-Tests fuer OrderManager, AuthService, SettingsSchema, LogStreamService und WebserverWorker (498 Tests gruen)
219 lines
8.2 KiB
C#
219 lines
8.2 KiB
C#
using FluentAssertions;
|
|
using IBKRTrader.Core.Logging;
|
|
using IBKRTrader.Core.Orders;
|
|
using IBKRTrader.Core.Persistence.Ef;
|
|
using IBKRTrader.Core.Persistence.Entities;
|
|
using IBKRTrader.Core.Settings;
|
|
using IBKRTrader.Core.Trading;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NSubstitute;
|
|
|
|
namespace IBKRTrader.Tests.Orders;
|
|
|
|
[Trait("cat", "unit")]
|
|
public class OrderManagerTests
|
|
{
|
|
private sealed class InMemoryFactory : IDbContextFactory<CoreDbContext>
|
|
{
|
|
private readonly DbContextOptions<CoreDbContext> _options;
|
|
public InMemoryFactory(DbContextOptions<CoreDbContext> options) => _options = options;
|
|
public CoreDbContext CreateDbContext() => new(_options);
|
|
}
|
|
|
|
private static (OrderManager Sut, IBrokerOrderBook OrderBook, IDbContextFactory<CoreDbContext> Dbf, SettingsService Settings) CreateSut()
|
|
{
|
|
var options = new DbContextOptionsBuilder<CoreDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
var factory = new InMemoryFactory(options);
|
|
var orderBook = Substitute.For<IBrokerOrderBook>();
|
|
var settings = new SettingsService();
|
|
var logger = new LoggingService();
|
|
|
|
var sut = new OrderManager(orderBook, factory, settings, logger);
|
|
return (sut, orderBook, factory, settings);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrderOverviewAsync_IdentifiesBackedAndUnbackedOrders()
|
|
{
|
|
var (sut, orderBook, dbf, _) = CreateSut();
|
|
|
|
// 1. Broker-Orders vorbereiten
|
|
var bo1 = new BrokerOrder(
|
|
OrderId: 101, PermId: 1001, Instrument: InstrumentRef.ForStock("AAPL"),
|
|
Side: TradeSide.Buy, Quantity: 10, Filled: 0, Remaining: 10,
|
|
OrderType: "LMT", LimitPrice: 150m, Status: "Submitted", Account: "U123");
|
|
|
|
var bo2 = new BrokerOrder(
|
|
OrderId: 102, PermId: 1002, Instrument: InstrumentRef.ForStock("MSFT"),
|
|
Side: TradeSide.Buy, Quantity: 5, Filled: 0, Remaining: 5,
|
|
OrderType: "LMT", LimitPrice: 400m, Status: "Submitted", Account: "U123");
|
|
|
|
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>())
|
|
.Returns([bo1, bo2]);
|
|
|
|
// 2. Eigene Kapitalbindung nur für bo1 anlegen
|
|
await using (var db = dbf.CreateDbContext())
|
|
{
|
|
db.Reservations.Add(new CoreReservation
|
|
{
|
|
Id = 1,
|
|
SignalId = "sig-1",
|
|
Book = "CT",
|
|
Side = "BUY",
|
|
Quantity = 10,
|
|
ContractKey = "AAPL",
|
|
Amount = 1500m,
|
|
Currency = "USD",
|
|
State = ReservationState.Submitted,
|
|
BrokerOrderId = "101",
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
var overview = await sut.GetOrderOverviewAsync();
|
|
|
|
// Assert
|
|
overview.BrokerReachable.Should().BeTrue();
|
|
overview.Orders.Should().HaveCount(2);
|
|
|
|
var managed1 = overview.Orders.First(o => o.BrokerOrder.OrderId == 101);
|
|
managed1.IsBackedByReservation.Should().BeTrue();
|
|
managed1.ReservationId.Should().Be(1);
|
|
managed1.Book.Should().Be("CT");
|
|
|
|
var managed2 = overview.Orders.First(o => o.BrokerOrder.OrderId == 102);
|
|
managed2.IsBackedByReservation.Should().BeFalse();
|
|
managed2.ReservationId.Should().BeNull();
|
|
|
|
overview.UnbackedOrders.Should().HaveCount(1);
|
|
overview.UnbackedOrders.Single().BrokerOrder.OrderId.Should().Be(102);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelOrderAsync_DelegatesToBrokerOrderBook()
|
|
{
|
|
var (sut, orderBook, _, _) = CreateSut();
|
|
orderBook.CancelOrderAsync(101, Arg.Any<CancellationToken>()).Returns(true);
|
|
|
|
var result = await sut.CancelOrderAsync(101);
|
|
|
|
result.Success.Should().BeTrue();
|
|
result.Message.Should().Contain("101");
|
|
await orderBook.Received(1).CancelOrderAsync(101, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelAllOrdersAsync_RejectsWhenNotPaperMode()
|
|
{
|
|
var (sut, orderBook, _, settings) = CreateSut();
|
|
settings.Settings.Trading.Mode = "Live";
|
|
|
|
var result = await sut.CancelAllOrdersAsync();
|
|
|
|
result.Success.Should().BeFalse();
|
|
result.Message.Should().Contain("Handelsmodus");
|
|
await orderBook.DidNotReceive().GetOpenOrdersAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelAllOrdersAsync_CancelsAllOrdersInPaperMode()
|
|
{
|
|
var (sut, orderBook, _, settings) = CreateSut();
|
|
settings.Settings.Trading.Mode = "Paper";
|
|
|
|
var bo1 = new BrokerOrder(
|
|
OrderId: 201, PermId: 2001, Instrument: InstrumentRef.ForStock("AAPL"),
|
|
Side: TradeSide.Buy, Quantity: 10, Filled: 0, Remaining: 10,
|
|
OrderType: "LMT", LimitPrice: 150m, Status: "Submitted", Account: "U123");
|
|
|
|
var bo2 = new BrokerOrder(
|
|
OrderId: 202, PermId: 2002, Instrument: InstrumentRef.ForStock("MSFT"),
|
|
Side: TradeSide.Buy, Quantity: 5, Filled: 0, Remaining: 5,
|
|
OrderType: "LMT", LimitPrice: 400m, Status: "Submitted", Account: "U123");
|
|
|
|
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>()).Returns([bo1, bo2]);
|
|
orderBook.CancelOrderAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(true);
|
|
|
|
var result = await sut.CancelAllOrdersAsync();
|
|
|
|
result.Success.Should().BeTrue();
|
|
result.Cancelled.Should().Be(2);
|
|
result.Total.Should().Be(2);
|
|
await orderBook.Received(1).CancelOrderAsync(201, Arg.Any<CancellationToken>());
|
|
await orderBook.Received(1).CancelOrderAsync(202, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrderOverviewAsync_WhenReservationHasNoBrokerOrderId_TreatedAsUnbacked()
|
|
{
|
|
var (sut, orderBook, dbf, _) = CreateSut();
|
|
|
|
var bo = new BrokerOrder(
|
|
OrderId: 301, PermId: 3001, Instrument: InstrumentRef.ForStock("NVDA"),
|
|
Side: TradeSide.Buy, Quantity: 20, Filled: 0, Remaining: 20,
|
|
OrderType: "LMT", LimitPrice: 120m, Status: "Submitted", Account: "U123");
|
|
|
|
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>()).Returns([bo]);
|
|
|
|
await using (var db = dbf.CreateDbContext())
|
|
{
|
|
db.Reservations.Add(new CoreReservation
|
|
{
|
|
Id = 10,
|
|
SignalId = "sig-nvda",
|
|
Book = "CT",
|
|
Side = "BUY",
|
|
Quantity = 20,
|
|
ContractKey = "NVDA",
|
|
Amount = 2400m,
|
|
Currency = "USD",
|
|
State = ReservationState.Reserved,
|
|
BrokerOrderId = null, // noch keine Order-ID zugewiesen
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var overview = await sut.GetOrderOverviewAsync();
|
|
|
|
overview.BrokerReachable.Should().BeTrue();
|
|
overview.Orders.Should().HaveCount(1);
|
|
var mo = overview.Orders.Single();
|
|
mo.IsBackedByReservation.Should().BeFalse();
|
|
overview.UnbackedOrders.Should().HaveCount(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrderOverviewAsync_HandlesBrokerException_Gracefully()
|
|
{
|
|
var (sut, orderBook, _, _) = CreateSut();
|
|
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>())
|
|
.Returns<IReadOnlyList<BrokerOrder>>(_ => throw new InvalidOperationException("Broker offline"));
|
|
|
|
var overview = await sut.GetOrderOverviewAsync();
|
|
|
|
overview.BrokerReachable.Should().BeFalse();
|
|
overview.Orders.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelOrderAsync_WhenBrokerReturnsFalse_ReportsFailure()
|
|
{
|
|
var (sut, orderBook, _, _) = CreateSut();
|
|
orderBook.CancelOrderAsync(999, Arg.Any<CancellationToken>()).Returns(false);
|
|
|
|
var result = await sut.CancelOrderAsync(999);
|
|
|
|
result.Success.Should().BeFalse();
|
|
result.Message.Should().Contain("NICHT bestätigt");
|
|
}
|
|
}
|
|
|
|
|