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