Files
IBKRTrader/Core/Trading/RiskService.cs
T
Richard 2ad4b55db1 @
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>
@
2026-07-27 11:33:59 +02:00

75 lines
3.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}