Phase 3: Trading-Kern (Risk, Execution, Portfolio) mit sicherem Broker-Default

- Core/Trading/TradingModels: Signal, Order(Request/Result), RiskContext/Decision,
  Account, Position, Quote, ExecutionResult, Enums (Side/OrderType/Mode)
- IBrokerClient + NullBrokerClient (sicherer Default, handelt NIE bis IBKR-Adapter verifiziert)
- RiskService (+IRiskService): Sizing nach MaxTrade%, Modul-Limit, Slippage; Buy/Sell
- PortfolioService (+IPortfolioService): core_position + core_trade_history + core_budget
- ExecutionService (+IExecutionService): Signal -> Kurs -> Konto -> Risiko -> Order -> Buchung
- TradingSettings in AppSettings (Paper/Live, TradingEnabled, Risikoparameter)
- CoreMigrations: core_position; DI-Registrierung der Trading-Services
- Tests: RiskService (11) + ExecutionService (6, NSubstitute) -> 38/38 gruen

Offen (bewusst gekapselt): echter IbkrBrokerClient gegen Client-Portal-Gateway (manuell verifizieren).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-27 11:33:59 +02:00
parent d0bc833235
commit 2ad4b55db1
15 changed files with 833 additions and 8 deletions
@@ -24,6 +24,7 @@ public class CoreMigrations
await CreateCoreWorkerLogAsync(); await CreateCoreWorkerLogAsync();
await CreateCoreTradeHistoryAsync(); await CreateCoreTradeHistoryAsync();
await CreateCoreBudgetAsync(); await CreateCoreBudgetAsync();
await CreateCorePositionAsync();
_logger.Info("Core", "Core-Migrationen abgeschlossen."); _logger.Info("Core", "Core-Migrationen abgeschlossen.");
} }
@@ -76,4 +77,15 @@ public class CoreMigrations
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
private Task CreateCorePositionAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `core_position` (
`module` VARCHAR(50) NOT NULL,
`symbol` VARCHAR(20) NOT NULL,
`quantity` INT NOT NULL DEFAULT 0,
`avg_price` DECIMAL(18,4) NOT NULL DEFAULT 0,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`module`, `symbol`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
} }
+48
View File
@@ -1,4 +1,5 @@
using System.ComponentModel; using System.ComponentModel;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Core.Settings; namespace IBKRTrader.Core.Settings;
@@ -171,6 +172,48 @@ public class WorkerSettings
public override string ToString() => "Worker-Konfiguration"; public override string ToString() => "Worker-Konfiguration";
} }
// ─── Trading ─────────────────────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
public class TradingSettings
{
[Category("Trading")]
[DisplayName("Modus")]
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
public string Mode { get; set; } = "Paper";
[Category("Trading")]
[DisplayName("Trading aktiv")]
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
public bool TradingEnabled { get; set; } = false;
[Category("Trading")]
[DisplayName("Max. je Trade (%)")]
[Description("Maximaler Nominalwert einer einzelnen Position in % des Kontowerts")]
public double MaxTradePercent { get; set; } = 5.0;
[Category("Trading")]
[DisplayName("Max. je Modul (%)")]
[Description("Maximaler Gesamt-Nominalwert aller Positionen eines Moduls in % des Kontowerts")]
public double MaxPositionPercentPerModule { get; set; } = 20.0;
[Category("Trading")]
[DisplayName("Max. Slippage (%)")]
[Description("Maximal erlaubte Abweichung zwischen Limit-Preis und aktuellem Kurs")]
public double MaxSlippagePercent { get; set; } = 5.0;
[Category("Trading")]
[DisplayName("Gewinnziel (%)")]
[Description("Ziel-Gewinn einer Position in Prozent (für spätere Exit-Logik)")]
public double ProfitTargetPercent { get; set; } = 50.0;
/// <summary>Parst den Modus in das Enum (Fallback: Paper).</summary>
public TradingMode ParsedMode =>
Enum.TryParse<TradingMode>(Mode, true, out var m) ? m : TradingMode.Paper;
public override string ToString() => $"{Mode} {(TradingEnabled ? "aktiv" : "inaktiv")}";
}
// ─── Root ──────────────────────────────────────────────────────────────────── // ─── Root ────────────────────────────────────────────────────────────────────
public class AppSettings public class AppSettings
@@ -204,4 +247,9 @@ public class AppSettings
[DisplayName("Worker-Einstellungen")] [DisplayName("Worker-Einstellungen")]
[Description("Zeitpläne der einzelnen Core-Worker")] [Description("Zeitpläne der einzelnen Core-Worker")]
public WorkerSettings WorkerSettings { get; set; } = new(); public WorkerSettings WorkerSettings { get; set; } = new();
[Category("Trading")]
[DisplayName("Trading")]
[Description("Handelsmodus und Risiko-Parameter")]
public TradingSettings Trading { get; set; } = new();
} }
+96
View File
@@ -0,0 +1,96 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung.
/// Kennt kein Modul Module rufen nur <see cref="ExecuteAsync"/> mit ihrem Signal auf.
/// </summary>
public sealed class ExecutionService : IExecutionService
{
private readonly IBrokerClient _broker;
private readonly IRiskService _risk;
private readonly IPortfolioService _portfolio;
private readonly SettingsService _settings;
private readonly LoggingService _logger;
public ExecutionService(
IBrokerClient broker,
IRiskService risk,
IPortfolioService portfolio,
SettingsService settings,
LoggingService logger)
{
_broker = broker;
_risk = risk;
_portfolio = portfolio;
_settings = settings;
_logger = logger;
}
public async Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default)
{
var trading = _settings.Settings.Trading;
var module = signal.SourceModule;
// 1. Globaler Hauptschalter
if (!trading.TradingEnabled)
return Log(module, ExecutionResult.Skip("Trading global deaktiviert."));
// 2. Kurs
var quote = await _broker.GetQuoteAsync(signal.Symbol, ct);
if (quote is null || quote.Last <= 0)
return Log(module, ExecutionResult.Skip($"Kein Kurs für {signal.Symbol} verfügbar."));
// 3. Konto + 4. bestehende Exposure/Position
var account = await _broker.GetAccountStateAsync(ct);
var exposure = await _portfolio.GetModuleExposureAsync(module, ct);
var existingQty = await _portfolio.GetPositionQuantityAsync(module, signal.Symbol, ct);
// 5. Risikoprüfung
var context = new RiskContext
{
Price = quote.Last,
NetLiquidation = account.NetLiquidation,
ModuleExposure = exposure,
ExistingQuantity = existingQty
};
var riskParams = new RiskParameters(
(decimal)trading.MaxTradePercent,
(decimal)trading.MaxPositionPercentPerModule,
(decimal)trading.MaxSlippagePercent);
var decision = _risk.Evaluate(signal, context, riskParams);
if (!decision.Approved)
return Log(module, ExecutionResult.Skip(decision.Reason));
// 6. Order platzieren
var order = new OrderRequest
{
Symbol = signal.Symbol,
Side = signal.Side,
Quantity = decision.Quantity,
Type = signal.LimitPrice.HasValue ? OrderType.Limit : OrderType.Market,
LimitPrice = signal.LimitPrice
};
var result = await _broker.PlaceOrderAsync(order, ct);
if (!result.Success)
return Log(module, ExecutionResult.Error(result.Error ?? "Order fehlgeschlagen.", result));
// 7. Buchung
await _portfolio.RecordFillAsync(
module, signal.Symbol, signal.Side,
result.FilledQuantity, result.AvgFillPrice, result.OrderId, ct);
return Log(module, ExecutionResult.Execute(result));
}
private ExecutionResult Log(string module, ExecutionResult result)
{
var text = $"[{result.Action}] {result.Reason}";
if (result.Action == "ERROR") _logger.Error(module, text);
else _logger.Info(module, text);
return result;
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Broker-Abstraktion (Kurse, Konto, Order-Ausführung).
/// Externer Seam: in Tests gemockt, produktiv von einem IBKR-Adapter implementiert.
/// </summary>
public interface IBrokerClient
{
/// <summary>Aktuelle Kurs-Momentaufnahme oder null, wenn nicht verfügbar.</summary>
Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default);
/// <summary>Kontostand-Momentaufnahme.</summary>
Task<AccountState> GetAccountStateAsync(CancellationToken ct = default);
/// <summary>Platziert eine Order und gibt das Ergebnis zurück.</summary>
Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default);
}
+10
View File
@@ -0,0 +1,10 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Zentrale Ausführungs-Pipeline. Module übergeben ihre <see cref="TradeSignal"/>e hier;
/// der Core prüft Risiko, führt aus und verbucht.
/// </summary>
public interface IExecutionService
{
Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default);
}
+22
View File
@@ -0,0 +1,22 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Buchführung über offene Positionen und Ausführungen (core_position, core_trade_history).
/// Externer Seam gegenüber der Datenbank: in Tests gemockt.
/// </summary>
public interface IPortfolioService
{
/// <summary>Summe des offenen Nominalwerts eines Moduls.</summary>
Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default);
/// <summary>Gehaltene Stückzahl eines Moduls für ein Symbol (0, wenn keine Position).</summary>
Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default);
/// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary>
Task RecordFillAsync(
string module, string symbol, TradeSide side,
int quantity, decimal price, string? orderId, CancellationToken ct = default);
/// <summary>Alle offenen Positionen eines Moduls.</summary>
Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default);
}
+7
View File
@@ -0,0 +1,7 @@
namespace IBKRTrader.Core.Trading;
/// <summary>Bewertet ein Signal gegen die Risiko-Parameter und liefert die Stückzahl.</summary>
public interface IRiskService
{
RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk);
}
+29
View File
@@ -0,0 +1,29 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Sicherer Standard-Broker: handelt NIEMALS.
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
/// </summary>
public sealed class NullBrokerClient : IBrokerClient
{
private readonly LoggingService _logger;
public NullBrokerClient(LoggingService logger) => _logger = logger;
public Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default)
=> Task.FromResult<Quote?>(null);
public Task<AccountState> GetAccountStateAsync(CancellationToken ct = default)
=> Task.FromResult(new AccountState(0m, 0m));
public Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
{
_logger.Warn("Core",
$"NullBrokerClient: Order NICHT ausgeführt ({request.Side} {request.Quantity}x {request.Symbol}) " +
" echter IBKR-Broker noch nicht angebunden.");
return Task.FromResult(OrderResult.Fail("Broker nicht angebunden (NullBrokerClient)."));
}
}
+110
View File
@@ -0,0 +1,110 @@
using IBKRTrader.Core.Budget;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Trading;
/// <summary>
/// DB-gestützte Buchführung über offene Positionen (core_position),
/// Trade-Historie (core_trade_history) und Budget (core_budget).
/// </summary>
public sealed class PortfolioService : IPortfolioService
{
private readonly DatabaseService _db;
private readonly TradeHistoryService _history;
private readonly BudgetService _budget;
private readonly LoggingService _logger;
public PortfolioService(
DatabaseService db,
TradeHistoryService history,
BudgetService budget,
LoggingService logger)
{
_db = db;
_history = history;
_budget = budget;
_logger = logger;
}
private sealed class PosDto
{
public int Quantity { get; set; }
public decimal AvgPrice { get; set; }
}
public async Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
{
var sum = await _db.ExecuteScalarAsync<decimal?>(
"SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module",
new { module });
return sum ?? 0m;
}
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
{
var row = await _db.QueryFirstOrDefaultAsync<PosDto>(
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
"WHERE module = @module AND symbol = @symbol",
new { module, symbol });
return row?.Quantity ?? 0;
}
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
{
var rows = await _db.QueryAsync<Position>(
"SELECT module AS Module, symbol AS Symbol, quantity AS Quantity, avg_price AS AvgPrice " +
"FROM `core_position` WHERE module = @module AND quantity > 0",
new { module });
return rows.ToList();
}
public async Task RecordFillAsync(
string module, string symbol, TradeSide side,
int quantity, decimal price, string? orderId, CancellationToken ct = default)
{
if (quantity <= 0) return;
var action = side == TradeSide.Buy ? "BUY" : "SELL";
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
var current = await _db.QueryFirstOrDefaultAsync<PosDto>(
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
"WHERE module = @module AND symbol = @symbol",
new { module, symbol });
var oldQty = current?.Quantity ?? 0;
var oldAvg = current?.AvgPrice ?? 0m;
if (side == TradeSide.Buy)
{
var newQty = oldQty + quantity;
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
await UpsertPositionAsync(module, symbol, newQty, newAvg);
await _budget.ReserveBudgetAsync(module, quantity * price);
}
else
{
var newQty = oldQty - quantity;
if (newQty <= 0)
await DeletePositionAsync(module, symbol);
else
await UpsertPositionAsync(module, symbol, newQty, oldAvg);
await _budget.ReleaseBudgetAsync(module, quantity * price);
}
_logger.Info(module, $"Position gebucht: {action} {quantity}x {symbol} @ {price:F2}");
}
private Task UpsertPositionAsync(string module, string symbol, int quantity, decimal avgPrice) =>
_db.ExecuteAsync(@"
INSERT INTO `core_position` (module, symbol, quantity, avg_price)
VALUES (@module, @symbol, @quantity, @avgPrice)
ON DUPLICATE KEY UPDATE quantity = @quantity, avg_price = @avgPrice",
new { module, symbol, quantity, avgPrice });
private Task DeletePositionAsync(string module, string symbol) =>
_db.ExecuteAsync(
"DELETE FROM `core_position` WHERE module = @module AND symbol = @symbol",
new { module, symbol });
}
+74
View File
@@ -0,0 +1,74 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Reine Risiko-/Sizing-Logik keine externen Abhängigkeiten, vollständig unit-testbar.
///
/// Regeln:
/// - Kauf: Nominalwert = min(Wunsch, Kontowert × MaxTradePercent); Stückzahl = floor(Nominal / Kurs).
/// Ablehnung bei ungültigem Kurs, Stückzahl &lt; 1, Überschreitung des Modul-Limits
/// oder zu hoher Slippage (bei Limit-Order).
/// - Verkauf: schließt die vorhandene Position (Stückzahl = gehaltene Menge);
/// Ablehnung, wenn keine Position vorhanden ist.
/// </summary>
public sealed class RiskService : IRiskService
{
public RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk)
{
if (context.Price <= 0)
return RiskDecision.Reject("Ungültiger Kurs (<= 0).");
if (SlippageTooHigh(signal, context, risk, out var slipReason))
return RiskDecision.Reject(slipReason);
return signal.Side == TradeSide.Sell
? EvaluateSell(context)
: EvaluateBuy(context, signal, risk);
}
private static RiskDecision EvaluateSell(RiskContext context)
{
if (context.ExistingQuantity <= 0)
return RiskDecision.Reject("Keine Position zum Verkauf vorhanden.");
return RiskDecision.Approve(context.ExistingQuantity, "Verkauf schließt Position.");
}
private static RiskDecision EvaluateBuy(RiskContext context, TradeSignal signal, RiskParameters risk)
{
if (context.NetLiquidation <= 0)
return RiskDecision.Reject("Kontowert unbekannt oder 0.");
var maxNotional = context.NetLiquidation * (risk.MaxTradePercent / 100m);
var notional = signal.SuggestedNotional is { } wish && wish > 0
? Math.Min(wish, maxNotional)
: maxNotional;
var quantity = (int)Math.Floor(notional / context.Price);
if (quantity < 1)
return RiskDecision.Reject("Positionsgröße < 1 Stück bei aktuellem Kurs/Budget.");
var projectedExposure = context.ModuleExposure + quantity * context.Price;
var moduleLimit = context.NetLiquidation * (risk.MaxPositionPercentPerModule / 100m);
if (projectedExposure > moduleLimit)
return RiskDecision.Reject(
$"Modul-Limit überschritten ({projectedExposure:F0} > {moduleLimit:F0}).");
return RiskDecision.Approve(quantity, $"{quantity} Stück freigegeben.");
}
private static bool SlippageTooHigh(
TradeSignal signal, RiskContext context, RiskParameters risk, out string reason)
{
reason = "";
if (signal.LimitPrice is not { } limit || limit <= 0)
return false;
var deviationPct = Math.Abs(context.Price - limit) / limit * 100m;
if (deviationPct > risk.MaxSlippagePercent)
{
reason = $"Slippage zu hoch ({deviationPct:F1}% > {risk.MaxSlippagePercent:F1}%).";
return true;
}
return false;
}
}
+124
View File
@@ -0,0 +1,124 @@
namespace IBKRTrader.Core.Trading;
/// <summary>Kauf oder Verkauf.</summary>
public enum TradeSide { Buy, Sell }
/// <summary>Order-Typ.</summary>
public enum OrderType { Market, Limit }
/// <summary>Handelsmodus Paper-Account (Test) oder Live.</summary>
public enum TradingMode { Paper, Live }
/// <summary>
/// Signal, das ein Modul an den <see cref="IExecutionService"/> übergibt.
/// Das Modul liefert nur die Absicht Sizing, Risiko und Ausführung macht der Core.
/// </summary>
public sealed record TradeSignal
{
/// <summary>Ticker-Symbol (z. B. "AAPL").</summary>
public required string Symbol { get; init; }
/// <summary>Kauf oder Verkauf.</summary>
public required TradeSide Side { get; init; }
/// <summary>Kürzel des auslösenden Moduls (z. B. "CT").</summary>
public required string SourceModule { get; init; }
/// <summary>Begründung des Signals (für Logging/Buchführung).</summary>
public string Reason { get; init; } = "";
/// <summary>Optionaler Limit-Preis. null = Market-Order.</summary>
public decimal? LimitPrice { get; init; }
/// <summary>Optionaler Nominalwert-Wunsch; sonst greift das Risiko-Sizing.</summary>
public decimal? SuggestedNotional { get; init; }
}
/// <summary>Konkrete Order-Anforderung an den Broker.</summary>
public sealed record OrderRequest
{
public required string Symbol { get; init; }
public required TradeSide Side { get; init; }
public required int Quantity { get; init; }
public required OrderType Type { get; init; }
public decimal? LimitPrice { get; init; }
}
/// <summary>Ergebnis einer Order-Platzierung.</summary>
public sealed record OrderResult
{
public bool Success { get; init; }
public string? OrderId { get; init; }
public int FilledQuantity { get; init; }
public decimal AvgFillPrice { get; init; }
public string? Error { get; init; }
public static OrderResult Filled(string orderId, int qty, decimal price) =>
new() { Success = true, OrderId = orderId, FilledQuantity = qty, AvgFillPrice = price };
public static OrderResult Fail(string error) =>
new() { Success = false, Error = error };
}
/// <summary>Momentaufnahme eines Kurses.</summary>
public sealed record Quote(string Symbol, decimal Last, decimal Bid, decimal Ask);
/// <summary>Kontostand-Momentaufnahme des Brokers.</summary>
public sealed record AccountState(decimal NetLiquidation, decimal AvailableFunds);
/// <summary>Offene Position eines Moduls.</summary>
public sealed record Position(string Module, string Symbol, int Quantity, decimal AvgPrice)
{
public decimal Notional => Quantity * AvgPrice;
}
/// <summary>Kontext für die Risikobewertung eines Signals.</summary>
public sealed record RiskContext
{
/// <summary>Aktueller Kurs des Symbols.</summary>
public required decimal Price { get; init; }
/// <summary>Netto-Liquidationswert des Kontos.</summary>
public required decimal NetLiquidation { get; init; }
/// <summary>Aktuell vom Modul gehaltener Nominalwert (Summe offener Positionen).</summary>
public decimal ModuleExposure { get; init; }
/// <summary>Bereits gehaltene Stückzahl für das Signal-Symbol.</summary>
public int ExistingQuantity { get; init; }
}
/// <summary>Aus den Settings abgeleitete Risiko-Parameter.</summary>
public sealed record RiskParameters(
decimal MaxTradePercent,
decimal MaxPositionPercentPerModule,
decimal MaxSlippagePercent);
/// <summary>Entscheidung der Risikoprüfung.</summary>
public sealed record RiskDecision
{
public bool Approved { get; init; }
public int Quantity { get; init; }
public string Reason { get; init; } = "";
public static RiskDecision Reject(string reason) =>
new() { Approved = false, Quantity = 0, Reason = reason };
public static RiskDecision Approve(int quantity, string reason = "OK") =>
new() { Approved = true, Quantity = quantity, Reason = reason };
}
/// <summary>Ergebnis einer Signal-Ausführung durch den <see cref="IExecutionService"/>.</summary>
public sealed record ExecutionResult
{
/// <summary>"EXECUTE", "SKIP" oder "ERROR".</summary>
public required string Action { get; init; }
public string Reason { get; init; } = "";
public OrderResult? Order { get; init; }
public bool Executed => Action == "EXECUTE";
public static ExecutionResult Skip(string reason) => new() { Action = "SKIP", Reason = reason };
public static ExecutionResult Error(string reason, OrderResult? order = null) => new() { Action = "ERROR", Reason = reason, Order = order };
public static ExecutionResult Execute(OrderResult order) => new() { Action = "EXECUTE", Reason = "OK", Order = order };
}
@@ -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");
}
}
+8
View File
@@ -47,6 +47,14 @@ internal static class Program
services.AddSingleton<TradeHistoryService>(); services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>(); services.AddSingleton<AIModelService>();
// Core: Trading-Kern
services.AddSingleton<IRiskService, RiskService>();
services.AddSingleton<IPortfolioService, PortfolioService>();
services.AddSingleton<IExecutionService, ExecutionService>();
// Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter
// angebunden und gegen den Paper-Gateway verifiziert ist.
services.AddSingleton<IBrokerClient, NullBrokerClient>();
// Core: Built-In Worker // Core: Built-In Worker
services.AddSingleton<BackupWorker>(); services.AddSingleton<BackupWorker>();
services.AddSingleton<WebserverService>(); services.AddSingleton<WebserverService>();
+9 -8
View File
@@ -117,14 +117,15 @@ WinForms selbst wird **nicht** unit-getestet Logik in Services/Manager halte
- [x] `WindowManager` in DI; Launcher schließt Modul-Fenster beim Beenden - [x] `WindowManager` in DI; Launcher schließt Modul-Fenster beim Beenden
- [x] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert - [x] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert
### Phase 3 Trading-Kern (Core) ### Phase 3 Trading-Kern (Core)
- [ ] `Core/Trading/IIbkrClient.cs` (+ Adapter auf `IBKRGatewayService`) - [x] `Core/Trading/TradingModels.cs` (Signal, Order, RiskContext/Decision, Account, Position, Quote)
- [ ] `Core/Trading/IOrderService.cs` + Implementierung (Market/Limit, Paper+Live) - [x] `Core/Trading/IBrokerClient.cs` + **`NullBrokerClient`** (sicherer Default: handelt nie)
- [ ] `Core/Trading/PortfolioService.cs` + Migrationen `core_position`, `core_trade`, `core_account_snapshot` - [x] `Core/Trading/PortfolioService.cs` (+ `IPortfolioService`) + Migration `core_position` (nutzt vorhandene `core_trade_history`/`core_budget`)
- [ ] `Core/Trading/RiskService.cs` (Sizing, Limits, Slippage, Profit-Target, globaler Pause-Schalter) - [x] `Core/Trading/RiskService.cs` (+ `IRiskService`): Sizing, Modul-Limit, Slippage
- [ ] `Core/Trading/ExecutionService.cs` (`TradeSignal` → Risiko → Order → Buchung) - [x] `Core/Trading/ExecutionService.cs` (+ `IExecutionService`): Signal → Kurs → Konto → Risiko → Order → Buchung
- [ ] `TradingSettings` in `AppSettings` (Mode Paper/Live, Risikoparameter) - [x] `TradingSettings` in `AppSettings` (Mode Paper/Live, TradingEnabled, Risikoparameter)
- [ ] Tests: `RiskService`, `ExecutionService` (voll gemockt) - [x] Tests: `RiskService` (11), `ExecutionService` (6, voll gemockt)**38/38 grün**
- [ ] **Offen (bewusst):** echter `IbkrBrokerClient` (Quote/Konto/Order gegen Client-Portal-Gateway) — manuelle Verifikation gegen Paper-Account
### Phase 4 CongressTrading als vollständige Strategie ### Phase 4 CongressTrading als vollständige Strategie
- [ ] `CongressTradingStrategy`: neue Scrape-Trades → `TradeSignal` an `ExecutionService` - [ ] `CongressTradingStrategy`: neue Scrape-Trades → `TradeSignal` an `ExecutionService`