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
+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 };
}