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> @
75 lines
3.0 KiB
C#
75 lines
3.0 KiB
C#
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 < 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;
|
||
}
|
||
}
|