@
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,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- net10.0-windows + WinForms, weil das Testprojekt die WinForms-Hauptassembly referenziert -->
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test-Fixture: gespeicherte capitoltrades.com-Seite (kein Netzzugriff im Test) -->
|
||||
<Content Include="..\..\ct_raw.html" Link="Fixtures\ct_raw.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,57 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||
|
||||
namespace IBKRTrader.Tests.Modules;
|
||||
|
||||
[Trait("cat", "unit")]
|
||||
public class CapitolTradesScraperTests
|
||||
{
|
||||
private static string LoadFixture()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "ct_raw.html");
|
||||
File.Exists(path).Should().BeTrue($"Fixture fehlt: {path}");
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTradesFromHtml_ExtractsTradesFromRscStream()
|
||||
{
|
||||
var scraper = new CapitolTradesScraper(new LoggingService());
|
||||
|
||||
var (trades, memberIds) = scraper.ParseTradesFromHtml(LoadFixture());
|
||||
|
||||
trades.Should().NotBeEmpty("die Fixture enthält einen RSC-Datenstrom mit Trades");
|
||||
memberIds.Should().NotBeEmpty();
|
||||
|
||||
var first = trades[0];
|
||||
first.TradeId.Should().NotBeNullOrWhiteSpace();
|
||||
first.TradeType.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTradesFromHtml_MapsMemberSnapshotConsistently()
|
||||
{
|
||||
var scraper = new CapitolTradesScraper(new LoggingService());
|
||||
|
||||
var (trades, memberIds) = scraper.ParseTradesFromHtml(LoadFixture());
|
||||
|
||||
// Jeder Member-Snapshot muss eine BioId haben, die auch im MemberBioIds-Set liegt.
|
||||
foreach (var trade in trades.Where(t => t.MemberSnapshot is not null))
|
||||
{
|
||||
trade.MemberSnapshot!.BioId.Should().NotBeNullOrWhiteSpace();
|
||||
memberIds.Should().Contain(trade.MemberBioId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades()
|
||||
{
|
||||
var scraper = new CapitolTradesScraper(new LoggingService());
|
||||
|
||||
var (trades, memberIds) = scraper.ParseTradesFromHtml("<html><body>nichts</body></html>");
|
||||
|
||||
trades.Should().BeEmpty();
|
||||
memberIds.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||
using IBKRTrader.Modules.CongressTrading.Workers;
|
||||
using IBKRTrader.UI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Tests.Modules;
|
||||
|
||||
[Trait("cat", "unit")]
|
||||
public class CongressTradingModuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Metadata_IsAsExpected()
|
||||
{
|
||||
var module = new CongressTradingModule();
|
||||
|
||||
module.Key.Should().Be("CT");
|
||||
module.DisplayName.Should().NotBeNullOrWhiteSpace();
|
||||
module.Version.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterServices_RegistersAllModuleServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
new CongressTradingModule().RegisterServices(services);
|
||||
|
||||
var registered = services.Select(d => d.ServiceType).ToList();
|
||||
registered.Should().Contain(new[]
|
||||
{
|
||||
typeof(CongressMigrations),
|
||||
typeof(CongressRepository),
|
||||
typeof(CapitolTradesScraper),
|
||||
typeof(CongressHistoryImportWorker),
|
||||
typeof(CongressScrapeWorker),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWindow_ReturnsModuleForm()
|
||||
{
|
||||
// Minimaler DI-Container mit allen von CreateWindow benötigten Abhängigkeiten.
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<LoggingService>();
|
||||
services.AddSingleton<DatabaseService>();
|
||||
services.AddSingleton<IEnumerable<IWorker>>(_ => Array.Empty<IWorker>());
|
||||
services.AddSingleton<WorkerEngine>();
|
||||
var module = new CongressTradingModule();
|
||||
module.RegisterServices(services);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var form = module.CreateWindow(provider);
|
||||
|
||||
form.Should().BeAssignableTo<ModuleFormBase>();
|
||||
((ModuleFormBase)form).ModuleKey.Should().Be("CT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Modules;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Tests.Modules;
|
||||
|
||||
[Trait("cat", "unit")]
|
||||
public class ModuleRegistryTests
|
||||
{
|
||||
private sealed class FakeModule(string key) : IModule
|
||||
{
|
||||
public string Key => key;
|
||||
public string DisplayName => $"Fake {key}";
|
||||
public string Description => "test";
|
||||
public string Version => "0.0.1";
|
||||
|
||||
public void RegisterServices(IServiceCollection services) { }
|
||||
public Task InitializeAsync(IServiceProvider provider) => Task.CompletedTask;
|
||||
public IEnumerable<IWorker> GetWorkers(IServiceProvider provider) => [];
|
||||
public Form CreateWindow(IServiceProvider provider) => new();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_PreservesRegistrationOrder()
|
||||
{
|
||||
var registry = new ModuleRegistry([new FakeModule("A"), new FakeModule("B")]);
|
||||
|
||||
registry.Modules.Select(m => m.Key).Should().ContainInOrder("A", "B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_IsCaseInsensitive()
|
||||
{
|
||||
var registry = new ModuleRegistry([new FakeModule("CT")]);
|
||||
|
||||
registry.Find("ct").Should().NotBeNull();
|
||||
registry.Find("CT").Should().NotBeNull();
|
||||
registry.Find("XX").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_NullModules_Throws()
|
||||
{
|
||||
var act = () => new ModuleRegistry(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Workers;
|
||||
|
||||
namespace IBKRTrader.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase-0-Smoke-Test: bestätigt, dass die Test-Infrastruktur steht und die
|
||||
/// Referenz auf die Hauptassembly (IBKRTrader) kompiliert und lädt.
|
||||
/// </summary>
|
||||
[Trait("cat", "unit")]
|
||||
public class SmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void MainAssembly_IsReferenced_AndDefaultsLoad()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
|
||||
settings.Should().NotBeNull();
|
||||
settings.IBKR.Should().NotBeNull();
|
||||
settings.Logging.Level.Should().Be("Info");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkerType_HasWorkerAndService()
|
||||
{
|
||||
Enum.GetNames<WorkerType>().Should().Contain(["Worker", "Service"]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.UI;
|
||||
|
||||
namespace IBKRTrader.Tests.UI;
|
||||
|
||||
[Trait("cat", "unit")]
|
||||
public class WindowManagerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Testbare Variante: unterdrückt echte Show-/Focus-Aufrufe (kein Message-Loop),
|
||||
/// zählt aber die Focus-Aufrufe.
|
||||
/// </summary>
|
||||
private sealed class TestWindowManager : WindowManager
|
||||
{
|
||||
public int FocusCount;
|
||||
protected override void Present(Form form) { /* kein Show im Test */ }
|
||||
protected override void Focus(Form form) => FocusCount++;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenOrFocus_NewKey_InvokesFactory_AndTracks()
|
||||
{
|
||||
var wm = new TestWindowManager();
|
||||
var created = 0;
|
||||
|
||||
using var form = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
|
||||
|
||||
created.Should().Be(1);
|
||||
wm.IsOpen("CT").Should().BeTrue();
|
||||
wm.OpenCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenOrFocus_SameKey_FocusesExisting_DoesNotRecreate()
|
||||
{
|
||||
var wm = new TestWindowManager();
|
||||
var created = 0;
|
||||
|
||||
using var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
|
||||
var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
|
||||
|
||||
created.Should().Be(1);
|
||||
wm.FocusCount.Should().Be(1);
|
||||
second.Should().BeSameAs(first);
|
||||
wm.OpenCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenOrFocus_DifferentKeys_TracksSeparately()
|
||||
{
|
||||
var wm = new TestWindowManager();
|
||||
|
||||
using var a = wm.OpenOrFocus("A", () => new Form());
|
||||
using var b = wm.OpenOrFocus("B", () => new Form());
|
||||
|
||||
wm.OpenCount.Should().Be(2);
|
||||
wm.IsOpen("A").Should().BeTrue();
|
||||
wm.IsOpen("B").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenOrFocus_AfterDisposed_RecreatesWindow()
|
||||
{
|
||||
var wm = new TestWindowManager();
|
||||
var created = 0;
|
||||
|
||||
var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
|
||||
first.Dispose();
|
||||
|
||||
wm.IsOpen("CT").Should().BeFalse();
|
||||
|
||||
using var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
|
||||
|
||||
created.Should().Be(2);
|
||||
second.Should().NotBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAll_RemovesAllWindows()
|
||||
{
|
||||
var wm = new TestWindowManager();
|
||||
wm.OpenOrFocus("A", () => new Form());
|
||||
wm.OpenOrFocus("B", () => new Form());
|
||||
|
||||
wm.CloseAll();
|
||||
|
||||
wm.OpenCount.Should().Be(0);
|
||||
wm.IsOpen("A").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenOrFocus_BlankKey_Throws()
|
||||
{
|
||||
var wm = new TestWindowManager();
|
||||
|
||||
var act = () => wm.OpenOrFocus(" ", () => new Form());
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Diagnostics;
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Workers;
|
||||
|
||||
namespace IBKRTrader.Tests.Workers;
|
||||
|
||||
[Trait("cat", "unit")]
|
||||
public class WorkerBaseTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Testbarer Worker: überschreibt den DB-Log-Seam (kein MySQL-Zugriff) und
|
||||
/// zählt seine Läufe. Zustände werden per Polling geprüft, weil WorkerBase
|
||||
/// Info.Status intern (und nach dem Log-Abschluss) setzt.
|
||||
/// </summary>
|
||||
private sealed class TestWorker : WorkerBase
|
||||
{
|
||||
private readonly TimeSpan? _interval;
|
||||
private readonly Func<CancellationToken, Task> _body;
|
||||
|
||||
public int Runs;
|
||||
|
||||
public override string Name => "TestWorker";
|
||||
public override string Module => "TEST";
|
||||
protected override TimeSpan? Interval => _interval;
|
||||
|
||||
public TestWorker(TimeSpan? interval, Func<CancellationToken, Task> body)
|
||||
: base(new LoggingService(),
|
||||
new DatabaseService(new SettingsService(), new LoggingService()))
|
||||
{
|
||||
_interval = interval;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref Runs);
|
||||
await _body(ct);
|
||||
}
|
||||
|
||||
// Seam überschreiben → kein DB-Zugriff. Positive Id, damit die End-Log-Aufrufe
|
||||
// (in WorkerBase mit `logId > 0` geschützt) auch im Fehlerpfad laufen.
|
||||
protected override Task<long> BeginRunLogAsync() => Task.FromResult(1L);
|
||||
protected override Task EndRunLogAsync(long logId, bool success, string? message = null)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (!condition())
|
||||
{
|
||||
if (sw.Elapsed > Timeout)
|
||||
throw new TimeoutException($"Bedingung nicht innerhalb {Timeout.TotalSeconds}s erfüllt: {because}");
|
||||
await Task.Delay(15);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunsOnce_WhenIntervalIsNull_AndReportsIdle()
|
||||
{
|
||||
var worker = new TestWorker(interval: null, _ => Task.CompletedTask);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
// Hinweis: WorkerBase startet bereits im Zustand Idle – deshalb auf den
|
||||
// abgeschlossenen Lauf warten (Runs == 1 UND wieder Idle).
|
||||
await WaitUntilAsync(
|
||||
() => worker.Runs == 1 && worker.Info.Status == WorkerStatus.Idle,
|
||||
"ein Lauf ist abgeschlossen und Status zurück auf Idle");
|
||||
|
||||
worker.Runs.Should().Be(1);
|
||||
|
||||
await worker.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Trigger_ForcesImmediateRun_BeforeIntervalElapses()
|
||||
{
|
||||
// Langes Intervall → ein zweiter Lauf kann nur durch Trigger entstehen.
|
||||
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
|
||||
|
||||
await worker.TriggerAsync();
|
||||
await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus");
|
||||
|
||||
await worker.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exception_SetsStatusError_AndCapturesMessage()
|
||||
{
|
||||
var worker = new TestWorker(interval: null,
|
||||
_ => throw new InvalidOperationException("boom"));
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() => worker.Info.Status == WorkerStatus.Error, "Status wird Error");
|
||||
|
||||
worker.Runs.Should().Be(1);
|
||||
worker.Info.Info.Should().Contain("boom");
|
||||
|
||||
await worker.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_SetsStatusStopped()
|
||||
{
|
||||
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
|
||||
await worker.StopAsync();
|
||||
|
||||
worker.Info.Status.Should().Be(WorkerStatus.Stopped);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user