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,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");
}
}