@
R1: Umbau auf Multi-Projekt-Struktur (Vorbild PolytraderSharp) Kurskorrektur: Vorbild ist PolytraderSharp (C#), nicht die veraltete Python-Version. Reiner Strukturumbau, Verhalten unveraendert. - src/IBKRTrader.Core (classlib): Core-Code + UI-Contract (ModuleFormBase, WindowManager) - src/IBKRTrader.Modules.CongressTrading (classlib, referenziert nur Core) - Root: IBKRTrader.App (WinExe) referenziert Core + Modul - tests/IBKRTrader.Tests: Referenzen auf Core + Modul - Neue .slnx; ungenutztes HtmlAgilityPack entfernt - docs/ARCHITECTURE.md: korrigierter Ziel-Stand + R-Phasenplan - Build + 38/38 Tests gruen; App startet Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Logging;
|
||||
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 ExecutionService CreateSut() =>
|
||||
new(_broker, _risk, _portfolio, _settings, new LoggingService());
|
||||
|
||||
private static readonly TradeSignal BuySignal = new()
|
||||
{
|
||||
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT"
|
||||
};
|
||||
|
||||
private void EnableTrading() => _settings.Settings.Trading.TradingEnabled = true;
|
||||
|
||||
private void ArrangeHappyPath()
|
||||
{
|
||||
EnableTrading();
|
||||
_broker.GetQuoteAsync(Arg.Any<string>(), 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<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(0));
|
||||
_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<string>(), 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<string>(), 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)));
|
||||
_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", "AAPL", TradeSide.Buy, 5, 100m, "O1", 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 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<string>(), Arg.Any<TradeSide>(),
|
||||
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Trading;
|
||||
|
||||
namespace IBKRTrader.Tests.Trading;
|
||||
|
||||
[Trait("cat", "unit")]
|
||||
public class RiskServiceTests
|
||||
{
|
||||
private readonly RiskService _risk = new();
|
||||
|
||||
// MaxTrade 5 %, MaxModul 20 %, MaxSlippage 5 %
|
||||
private static readonly RiskParameters DefaultParams = new(5m, 20m, 5m);
|
||||
|
||||
private static TradeSignal Buy(decimal? limit = null, decimal? notional = null) => new()
|
||||
{
|
||||
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT",
|
||||
LimitPrice = limit, SuggestedNotional = notional
|
||||
};
|
||||
|
||||
private static TradeSignal Sell() => new()
|
||||
{
|
||||
Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Buy_SizesByMaxTradePercent()
|
||||
{
|
||||
// NetLiq 10.000 × 5 % = 500 max Nominal; Kurs 100 → 5 Stück
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
|
||||
|
||||
var d = _risk.Evaluate(Buy(), ctx, DefaultParams);
|
||||
|
||||
d.Approved.Should().BeTrue();
|
||||
d.Quantity.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_UsesSuggestedNotional_WhenSmallerThanMax()
|
||||
{
|
||||
// Wunsch 200 < Max 500; Kurs 100 → 2 Stück
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
|
||||
|
||||
var d = _risk.Evaluate(Buy(notional: 200m), ctx, DefaultParams);
|
||||
|
||||
d.Quantity.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_InvalidPrice_Rejected()
|
||||
{
|
||||
var ctx = new RiskContext { Price = 0m, NetLiquidation = 10_000m };
|
||||
|
||||
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_ZeroAccount_Rejected()
|
||||
{
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 0m };
|
||||
|
||||
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_QuantityBelowOne_Rejected()
|
||||
{
|
||||
// NetLiq 100 × 5 % = 5 max Nominal; Kurs 100 → 0 Stück
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 100m };
|
||||
|
||||
var d = _risk.Evaluate(Buy(), ctx, DefaultParams);
|
||||
|
||||
d.Approved.Should().BeFalse();
|
||||
d.Reason.Should().Contain("< 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_ExceedsModuleLimit_Rejected()
|
||||
{
|
||||
// MaxTrade 50 % → 5.000 Nominal, Kurs 100 → 50 Stück = 5.000
|
||||
// Modul-Limit 20 % × 10.000 = 2.000 → abgelehnt
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
|
||||
var pars = new RiskParameters(50m, 20m, 5m);
|
||||
|
||||
var d = _risk.Evaluate(Buy(), ctx, pars);
|
||||
|
||||
d.Approved.Should().BeFalse();
|
||||
d.Reason.Should().Contain("Modul-Limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_ExistingExposureCountsTowardModuleLimit()
|
||||
{
|
||||
// Kurs 100, Max 5 % → 5 Stück (500). Bereits 1.700 Exposure.
|
||||
// Projektiert 2.200 > Limit 2.000 → abgelehnt.
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ModuleExposure = 1_700m };
|
||||
|
||||
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_LimitOrder_SlippageTooHigh_Rejected()
|
||||
{
|
||||
// Limit 100, Kurs 110 → 10 % > 5 %
|
||||
var ctx = new RiskContext { Price = 110m, NetLiquidation = 10_000m };
|
||||
|
||||
var d = _risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams);
|
||||
|
||||
d.Approved.Should().BeFalse();
|
||||
d.Reason.Should().Contain("Slippage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buy_LimitOrder_SlippageWithinTolerance_Approved()
|
||||
{
|
||||
// Limit 100, Kurs 104 → 4 % < 5 %
|
||||
var ctx = new RiskContext { Price = 104m, NetLiquidation = 10_000m };
|
||||
|
||||
_risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams).Approved.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sell_WithPosition_ClosesQuantity()
|
||||
{
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 12 };
|
||||
|
||||
var d = _risk.Evaluate(Sell(), ctx, DefaultParams);
|
||||
|
||||
d.Approved.Should().BeTrue();
|
||||
d.Quantity.Should().Be(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sell_WithoutPosition_Rejected()
|
||||
{
|
||||
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 0 };
|
||||
|
||||
var d = _risk.Evaluate(Sell(), ctx, DefaultParams);
|
||||
|
||||
d.Approved.Should().BeFalse();
|
||||
d.Reason.Should().Contain("Keine Position");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user