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:
Richard
2026-07-27 20:26:25 +02:00
parent 2ad4b55db1
commit c20538c8d8
61 changed files with 171 additions and 129 deletions
@@ -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>());
}
}