Portierung der beiden fehlenden Grundbausteine aus PolytraderSharp (voller Ausbau). Core S-0 (Datenfundament fuer Analyse/Forensik): - core_decision_journal + core_order_events (+ ReasonCode/Decision/OrderEvent-Enums), IDecisionJournal/IOrderEventLog mit fehlertoleranten EF-Impls (Handel bricht nie). - SignalId-Durchreichung TradeSignal -> ExecutionService -> core_trade_history; ExecutionService schreibt an jeder Verzweigung Journal/Order-Events. - JSONL-Log-Sink (LogJson + Dual-Sink), pure Analytik: RealizedPnlEngine (FIFO), TradeAnalytics, DossierBuilder. Migration AddAnalysisFoundation. Accounting-Modul (acc_): unabhaengiger IBKR-Kontoauszug (Activity Flex Query) hinter Interfaces mit Offline-Null-Stubs -> append-only Ledger + Periodenabrechnung/BWA + FX (USD/EUR) + CSV/PDF (PDFsharp/MigraDoc). Steuerschicht bewusst offen (Platzhalter-Tab). Kein Handel. Migration InitialAccounting. Supervisor-Modul (sup_): read-only OpenRouter-Agent (Function-Calling-Loop) + read-only Tool-Registry (8 Tools) + Profile + Dossier-Browser + Counterfactual-Job (Stub) + Tagesbericht/MCP-Light (opt-in). Migration InitialSupervisor. Verdrahtung: Program.cs (beide Module + Icons), slnx/App/Tests-Referenzen, provision-db.ps1, AppSettings-Sektionen, docs/konzepte, README. Tests: 79 -> 117 gruen (FIFO/KPIs/Dossier/JSONL, Classifier/Engine/FX/Idempotenz, OpenRouter/Registry/Agent/MCP, STA-Konstruktion beider neuen Fenster). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
6.5 KiB
C#
157 lines
6.5 KiB
C#
using FluentAssertions;
|
|
using IBKRTrader.Core.Logging;
|
|
using IBKRTrader.Core.Persistence;
|
|
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 readonly IDecisionJournal _journal = Substitute.For<IDecisionJournal>();
|
|
private readonly IOrderEventLog _orderLog = Substitute.For<IOrderEventLog>();
|
|
|
|
private ExecutionService CreateSut() =>
|
|
new(_broker, _risk, _portfolio, _settings, new LoggingService(), _journal, _orderLog);
|
|
|
|
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<string?>(), 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 TradingDisabled_WritesSkippedDecision()
|
|
{
|
|
await CreateSut().ExecuteAsync(BuySignal);
|
|
|
|
_journal.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord>(
|
|
d => d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Skipped &&
|
|
d.Reason == IBKRTrader.Core.Persistence.Entities.DecisionReason.TradingDisabled));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HappyPath_PropagatesSignalId_AndJournalsExecuted()
|
|
{
|
|
ArrangeHappyPath();
|
|
var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT", SignalId = "sig-abc" };
|
|
|
|
await CreateSut().ExecuteAsync(signal);
|
|
|
|
await _portfolio.Received(1).RecordFillAsync(
|
|
"CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", "sig-abc", Arg.Any<CancellationToken>());
|
|
_journal.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord>(
|
|
d => d.SignalId == "sig-abc" &&
|
|
d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Executed));
|
|
_orderLog.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreOrderEvent>(
|
|
e => e.SignalId == "sig-abc" &&
|
|
e.EventType == IBKRTrader.Core.Persistence.Entities.OrderEventType.Filled));
|
|
}
|
|
|
|
[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<string?>(), Arg.Any<CancellationToken>());
|
|
}
|
|
}
|