R1: Umbau auf Multi-Projekt-Struktur (Vorbild PolytraderSharp) Kurskorrektur: Vorbild ist PolytraderSharp (C#), nicht die veraltete Python-Version. Reiner Strukturumbau, Verhalten unveraendert. - src/IBKRTrader.Core (classlib): Core-Code + UI-Contract (ModuleFormBase, WindowManager) - src/IBKRTrader.Modules.CongressTrading (classlib, referenziert nur Core) - Root: IBKRTrader.App (WinExe) referenziert Core + Modul - tests/IBKRTrader.Tests: Referenzen auf Core + Modul - Neue .slnx; ungenutztes HtmlAgilityPack entfernt - docs/ARCHITECTURE.md: korrigierter Ziel-Stand + R-Phasenplan - Build + 38/38 Tests gruen; App startet 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;
|
||
}
|
||
}
|