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