@
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:
@@ -24,6 +24,7 @@ public class CoreMigrations
|
|||||||
await CreateCoreWorkerLogAsync();
|
await CreateCoreWorkerLogAsync();
|
||||||
await CreateCoreTradeHistoryAsync();
|
await CreateCoreTradeHistoryAsync();
|
||||||
await CreateCoreBudgetAsync();
|
await CreateCoreBudgetAsync();
|
||||||
|
await CreateCorePositionAsync();
|
||||||
_logger.Info("Core", "Core-Migrationen abgeschlossen.");
|
_logger.Info("Core", "Core-Migrationen abgeschlossen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,4 +77,15 @@ public class CoreMigrations
|
|||||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
|
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
ON UPDATE CURRENT_TIMESTAMP
|
ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||||
|
|
||||||
|
private Task CreateCorePositionAsync() => _db.ExecuteAsync(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS `core_position` (
|
||||||
|
`module` VARCHAR(50) NOT NULL,
|
||||||
|
`symbol` VARCHAR(20) NOT NULL,
|
||||||
|
`quantity` INT NOT NULL DEFAULT 0,
|
||||||
|
`avg_price` DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||||
|
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`module`, `symbol`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Settings;
|
namespace IBKRTrader.Core.Settings;
|
||||||
|
|
||||||
@@ -171,6 +172,48 @@ public class WorkerSettings
|
|||||||
public override string ToString() => "Worker-Konfiguration";
|
public override string ToString() => "Worker-Konfiguration";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Trading ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||||
|
public class TradingSettings
|
||||||
|
{
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Modus")]
|
||||||
|
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
|
||||||
|
public string Mode { get; set; } = "Paper";
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Trading aktiv")]
|
||||||
|
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
|
||||||
|
public bool TradingEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Max. je Trade (%)")]
|
||||||
|
[Description("Maximaler Nominalwert einer einzelnen Position in % des Kontowerts")]
|
||||||
|
public double MaxTradePercent { get; set; } = 5.0;
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Max. je Modul (%)")]
|
||||||
|
[Description("Maximaler Gesamt-Nominalwert aller Positionen eines Moduls in % des Kontowerts")]
|
||||||
|
public double MaxPositionPercentPerModule { get; set; } = 20.0;
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Max. Slippage (%)")]
|
||||||
|
[Description("Maximal erlaubte Abweichung zwischen Limit-Preis und aktuellem Kurs")]
|
||||||
|
public double MaxSlippagePercent { get; set; } = 5.0;
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Gewinnziel (%)")]
|
||||||
|
[Description("Ziel-Gewinn einer Position in Prozent (für spätere Exit-Logik)")]
|
||||||
|
public double ProfitTargetPercent { get; set; } = 50.0;
|
||||||
|
|
||||||
|
/// <summary>Parst den Modus in das Enum (Fallback: Paper).</summary>
|
||||||
|
public TradingMode ParsedMode =>
|
||||||
|
Enum.TryParse<TradingMode>(Mode, true, out var m) ? m : TradingMode.Paper;
|
||||||
|
|
||||||
|
public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Root ────────────────────────────────────────────────────────────────────
|
// ─── Root ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class AppSettings
|
public class AppSettings
|
||||||
@@ -204,4 +247,9 @@ public class AppSettings
|
|||||||
[DisplayName("Worker-Einstellungen")]
|
[DisplayName("Worker-Einstellungen")]
|
||||||
[Description("Zeitpläne der einzelnen Core-Worker")]
|
[Description("Zeitpläne der einzelnen Core-Worker")]
|
||||||
public WorkerSettings WorkerSettings { get; set; } = new();
|
public WorkerSettings WorkerSettings { get; set; } = new();
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Trading")]
|
||||||
|
[Description("Handelsmodus und Risiko-Parameter")]
|
||||||
|
public TradingSettings Trading { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broker-Abstraktion (Kurse, Konto, Order-Ausführung).
|
||||||
|
/// Externer Seam: in Tests gemockt, produktiv von einem IBKR-Adapter implementiert.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBrokerClient
|
||||||
|
{
|
||||||
|
/// <summary>Aktuelle Kurs-Momentaufnahme oder null, wenn nicht verfügbar.</summary>
|
||||||
|
Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Kontostand-Momentaufnahme.</summary>
|
||||||
|
Task<AccountState> GetAccountStateAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Platziert eine Order und gibt das Ergebnis zurück.</summary>
|
||||||
|
Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zentrale Ausführungs-Pipeline. Module übergeben ihre <see cref="TradeSignal"/>e hier;
|
||||||
|
/// der Core prüft Risiko, führt aus und verbucht.
|
||||||
|
/// </summary>
|
||||||
|
public interface IExecutionService
|
||||||
|
{
|
||||||
|
Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buchführung über offene Positionen und Ausführungen (core_position, core_trade_history).
|
||||||
|
/// Externer Seam gegenüber der Datenbank: in Tests gemockt.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPortfolioService
|
||||||
|
{
|
||||||
|
/// <summary>Summe des offenen Nominalwerts eines Moduls.</summary>
|
||||||
|
Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Gehaltene Stückzahl eines Moduls für ein Symbol (0, wenn keine Position).</summary>
|
||||||
|
Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary>
|
||||||
|
Task RecordFillAsync(
|
||||||
|
string module, string symbol, TradeSide side,
|
||||||
|
int quantity, decimal price, string? orderId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Alle offenen Positionen eines Moduls.</summary>
|
||||||
|
Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>Bewertet ein Signal gegen die Risiko-Parameter und liefert die Stückzahl.</summary>
|
||||||
|
public interface IRiskService
|
||||||
|
{
|
||||||
|
RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using IBKRTrader.Core.Logging;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sicherer Standard-Broker: handelt NIEMALS.
|
||||||
|
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
|
||||||
|
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullBrokerClient : IBrokerClient
|
||||||
|
{
|
||||||
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
|
public NullBrokerClient(LoggingService logger) => _logger = logger;
|
||||||
|
|
||||||
|
public Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<Quote?>(null);
|
||||||
|
|
||||||
|
public Task<AccountState> GetAccountStateAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new AccountState(0m, 0m));
|
||||||
|
|
||||||
|
public Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.Warn("Core",
|
||||||
|
$"NullBrokerClient: Order NICHT ausgeführt ({request.Side} {request.Quantity}x {request.Symbol}) " +
|
||||||
|
"– echter IBKR-Broker noch nicht angebunden.");
|
||||||
|
return Task.FromResult(OrderResult.Fail("Broker nicht angebunden (NullBrokerClient)."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using IBKRTrader.Core.Budget;
|
||||||
|
using IBKRTrader.Core.Database;
|
||||||
|
using IBKRTrader.Core.Logging;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DB-gestützte Buchführung über offene Positionen (core_position),
|
||||||
|
/// Trade-Historie (core_trade_history) und Budget (core_budget).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PortfolioService : IPortfolioService
|
||||||
|
{
|
||||||
|
private readonly DatabaseService _db;
|
||||||
|
private readonly TradeHistoryService _history;
|
||||||
|
private readonly BudgetService _budget;
|
||||||
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
|
public PortfolioService(
|
||||||
|
DatabaseService db,
|
||||||
|
TradeHistoryService history,
|
||||||
|
BudgetService budget,
|
||||||
|
LoggingService logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_history = history;
|
||||||
|
_budget = budget;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PosDto
|
||||||
|
{
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public decimal AvgPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sum = await _db.ExecuteScalarAsync<decimal?>(
|
||||||
|
"SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module",
|
||||||
|
new { module });
|
||||||
|
return sum ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var row = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
||||||
|
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
||||||
|
"WHERE module = @module AND symbol = @symbol",
|
||||||
|
new { module, symbol });
|
||||||
|
return row?.Quantity ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = await _db.QueryAsync<Position>(
|
||||||
|
"SELECT module AS Module, symbol AS Symbol, quantity AS Quantity, avg_price AS AvgPrice " +
|
||||||
|
"FROM `core_position` WHERE module = @module AND quantity > 0",
|
||||||
|
new { module });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecordFillAsync(
|
||||||
|
string module, string symbol, TradeSide side,
|
||||||
|
int quantity, decimal price, string? orderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (quantity <= 0) return;
|
||||||
|
|
||||||
|
var action = side == TradeSide.Buy ? "BUY" : "SELL";
|
||||||
|
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
|
||||||
|
|
||||||
|
var current = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
||||||
|
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
||||||
|
"WHERE module = @module AND symbol = @symbol",
|
||||||
|
new { module, symbol });
|
||||||
|
|
||||||
|
var oldQty = current?.Quantity ?? 0;
|
||||||
|
var oldAvg = current?.AvgPrice ?? 0m;
|
||||||
|
|
||||||
|
if (side == TradeSide.Buy)
|
||||||
|
{
|
||||||
|
var newQty = oldQty + quantity;
|
||||||
|
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
|
||||||
|
await UpsertPositionAsync(module, symbol, newQty, newAvg);
|
||||||
|
await _budget.ReserveBudgetAsync(module, quantity * price);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newQty = oldQty - quantity;
|
||||||
|
if (newQty <= 0)
|
||||||
|
await DeletePositionAsync(module, symbol);
|
||||||
|
else
|
||||||
|
await UpsertPositionAsync(module, symbol, newQty, oldAvg);
|
||||||
|
await _budget.ReleaseBudgetAsync(module, quantity * price);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Info(module, $"Position gebucht: {action} {quantity}x {symbol} @ {price:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task UpsertPositionAsync(string module, string symbol, int quantity, decimal avgPrice) =>
|
||||||
|
_db.ExecuteAsync(@"
|
||||||
|
INSERT INTO `core_position` (module, symbol, quantity, avg_price)
|
||||||
|
VALUES (@module, @symbol, @quantity, @avgPrice)
|
||||||
|
ON DUPLICATE KEY UPDATE quantity = @quantity, avg_price = @avgPrice",
|
||||||
|
new { module, symbol, quantity, avgPrice });
|
||||||
|
|
||||||
|
private Task DeletePositionAsync(string module, string symbol) =>
|
||||||
|
_db.ExecuteAsync(
|
||||||
|
"DELETE FROM `core_position` WHERE module = @module AND symbol = @symbol",
|
||||||
|
new { module, symbol });
|
||||||
|
}
|
||||||
@@ -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 < 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>Kauf oder Verkauf.</summary>
|
||||||
|
public enum TradeSide { Buy, Sell }
|
||||||
|
|
||||||
|
/// <summary>Order-Typ.</summary>
|
||||||
|
public enum OrderType { Market, Limit }
|
||||||
|
|
||||||
|
/// <summary>Handelsmodus – Paper-Account (Test) oder Live.</summary>
|
||||||
|
public enum TradingMode { Paper, Live }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signal, das ein Modul an den <see cref="IExecutionService"/> übergibt.
|
||||||
|
/// Das Modul liefert nur die Absicht – Sizing, Risiko und Ausführung macht der Core.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TradeSignal
|
||||||
|
{
|
||||||
|
/// <summary>Ticker-Symbol (z. B. "AAPL").</summary>
|
||||||
|
public required string Symbol { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Kauf oder Verkauf.</summary>
|
||||||
|
public required TradeSide Side { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Kürzel des auslösenden Moduls (z. B. "CT").</summary>
|
||||||
|
public required string SourceModule { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Begründung des Signals (für Logging/Buchführung).</summary>
|
||||||
|
public string Reason { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Optionaler Limit-Preis. null = Market-Order.</summary>
|
||||||
|
public decimal? LimitPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optionaler Nominalwert-Wunsch; sonst greift das Risiko-Sizing.</summary>
|
||||||
|
public decimal? SuggestedNotional { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Konkrete Order-Anforderung an den Broker.</summary>
|
||||||
|
public sealed record OrderRequest
|
||||||
|
{
|
||||||
|
public required string Symbol { get; init; }
|
||||||
|
public required TradeSide Side { get; init; }
|
||||||
|
public required int Quantity { get; init; }
|
||||||
|
public required OrderType Type { get; init; }
|
||||||
|
public decimal? LimitPrice { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ergebnis einer Order-Platzierung.</summary>
|
||||||
|
public sealed record OrderResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string? OrderId { get; init; }
|
||||||
|
public int FilledQuantity { get; init; }
|
||||||
|
public decimal AvgFillPrice { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
|
||||||
|
public static OrderResult Filled(string orderId, int qty, decimal price) =>
|
||||||
|
new() { Success = true, OrderId = orderId, FilledQuantity = qty, AvgFillPrice = price };
|
||||||
|
|
||||||
|
public static OrderResult Fail(string error) =>
|
||||||
|
new() { Success = false, Error = error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Momentaufnahme eines Kurses.</summary>
|
||||||
|
public sealed record Quote(string Symbol, decimal Last, decimal Bid, decimal Ask);
|
||||||
|
|
||||||
|
/// <summary>Kontostand-Momentaufnahme des Brokers.</summary>
|
||||||
|
public sealed record AccountState(decimal NetLiquidation, decimal AvailableFunds);
|
||||||
|
|
||||||
|
/// <summary>Offene Position eines Moduls.</summary>
|
||||||
|
public sealed record Position(string Module, string Symbol, int Quantity, decimal AvgPrice)
|
||||||
|
{
|
||||||
|
public decimal Notional => Quantity * AvgPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kontext für die Risikobewertung eines Signals.</summary>
|
||||||
|
public sealed record RiskContext
|
||||||
|
{
|
||||||
|
/// <summary>Aktueller Kurs des Symbols.</summary>
|
||||||
|
public required decimal Price { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Netto-Liquidationswert des Kontos.</summary>
|
||||||
|
public required decimal NetLiquidation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Aktuell vom Modul gehaltener Nominalwert (Summe offener Positionen).</summary>
|
||||||
|
public decimal ModuleExposure { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Bereits gehaltene Stückzahl für das Signal-Symbol.</summary>
|
||||||
|
public int ExistingQuantity { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aus den Settings abgeleitete Risiko-Parameter.</summary>
|
||||||
|
public sealed record RiskParameters(
|
||||||
|
decimal MaxTradePercent,
|
||||||
|
decimal MaxPositionPercentPerModule,
|
||||||
|
decimal MaxSlippagePercent);
|
||||||
|
|
||||||
|
/// <summary>Entscheidung der Risikoprüfung.</summary>
|
||||||
|
public sealed record RiskDecision
|
||||||
|
{
|
||||||
|
public bool Approved { get; init; }
|
||||||
|
public int Quantity { get; init; }
|
||||||
|
public string Reason { get; init; } = "";
|
||||||
|
|
||||||
|
public static RiskDecision Reject(string reason) =>
|
||||||
|
new() { Approved = false, Quantity = 0, Reason = reason };
|
||||||
|
|
||||||
|
public static RiskDecision Approve(int quantity, string reason = "OK") =>
|
||||||
|
new() { Approved = true, Quantity = quantity, Reason = reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ergebnis einer Signal-Ausführung durch den <see cref="IExecutionService"/>.</summary>
|
||||||
|
public sealed record ExecutionResult
|
||||||
|
{
|
||||||
|
/// <summary>"EXECUTE", "SKIP" oder "ERROR".</summary>
|
||||||
|
public required string Action { get; init; }
|
||||||
|
public string Reason { get; init; } = "";
|
||||||
|
public OrderResult? Order { get; init; }
|
||||||
|
|
||||||
|
public bool Executed => Action == "EXECUTE";
|
||||||
|
|
||||||
|
public static ExecutionResult Skip(string reason) => new() { Action = "SKIP", Reason = reason };
|
||||||
|
public static ExecutionResult Error(string reason, OrderResult? order = null) => new() { Action = "ERROR", Reason = reason, Order = order };
|
||||||
|
public static ExecutionResult Execute(OrderResult order) => new() { Action = "EXECUTE", Reason = "OK", Order = order };
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Settings;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Tests.Trading;
|
||||||
|
|
||||||
|
[Trait("cat", "unit")]
|
||||||
|
public class ExecutionServiceTests
|
||||||
|
{
|
||||||
|
private readonly IBrokerClient _broker = Substitute.For<IBrokerClient>();
|
||||||
|
private readonly IRiskService _risk = Substitute.For<IRiskService>();
|
||||||
|
private readonly IPortfolioService _portfolio = Substitute.For<IPortfolioService>();
|
||||||
|
private readonly SettingsService _settings = new();
|
||||||
|
|
||||||
|
private ExecutionService CreateSut() =>
|
||||||
|
new(_broker, _risk, _portfolio, _settings, new LoggingService());
|
||||||
|
|
||||||
|
private static readonly TradeSignal BuySignal = new()
|
||||||
|
{
|
||||||
|
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT"
|
||||||
|
};
|
||||||
|
|
||||||
|
private void EnableTrading() => _settings.Settings.Trading.TradingEnabled = true;
|
||||||
|
|
||||||
|
private void ArrangeHappyPath()
|
||||||
|
{
|
||||||
|
EnableTrading();
|
||||||
|
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
|
||||||
|
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
|
||||||
|
_portfolio.GetModuleExposureAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(0m));
|
||||||
|
_portfolio.GetPositionQuantityAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(0));
|
||||||
|
_risk.Evaluate(Arg.Any<TradeSignal>(), Arg.Any<RiskContext>(), Arg.Any<RiskParameters>())
|
||||||
|
.Returns(RiskDecision.Approve(5));
|
||||||
|
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(OrderResult.Filled("O1", 5, 100m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TradingDisabled_Skips_WithoutTouchingBroker()
|
||||||
|
{
|
||||||
|
// TradingEnabled ist standardmäßig false
|
||||||
|
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||||||
|
|
||||||
|
result.Action.Should().Be("SKIP");
|
||||||
|
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoQuote_Skips()
|
||||||
|
{
|
||||||
|
EnableTrading();
|
||||||
|
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<Quote?>(null));
|
||||||
|
|
||||||
|
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||||||
|
|
||||||
|
result.Action.Should().Be("SKIP");
|
||||||
|
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RiskRejects_Skips_WithReason()
|
||||||
|
{
|
||||||
|
EnableTrading();
|
||||||
|
_broker.GetQuoteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<Quote?>(new Quote("AAPL", 100m, 99m, 101m)));
|
||||||
|
_broker.GetAccountStateAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(new AccountState(10_000m, 10_000m)));
|
||||||
|
_risk.Evaluate(Arg.Any<TradeSignal>(), Arg.Any<RiskContext>(), Arg.Any<RiskParameters>())
|
||||||
|
.Returns(RiskDecision.Reject("Modul-Limit überschritten."));
|
||||||
|
|
||||||
|
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||||||
|
|
||||||
|
result.Action.Should().Be("SKIP");
|
||||||
|
result.Reason.Should().Contain("Modul-Limit");
|
||||||
|
await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HappyPath_PlacesOrder_AndBooksFill()
|
||||||
|
{
|
||||||
|
ArrangeHappyPath();
|
||||||
|
|
||||||
|
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||||||
|
|
||||||
|
result.Executed.Should().BeTrue();
|
||||||
|
result.Order!.OrderId.Should().Be("O1");
|
||||||
|
await _portfolio.Received(1).RecordFillAsync(
|
||||||
|
"CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HappyPath_MarketOrder_WhenNoLimitPrice()
|
||||||
|
{
|
||||||
|
ArrangeHappyPath();
|
||||||
|
|
||||||
|
await CreateSut().ExecuteAsync(BuySignal);
|
||||||
|
|
||||||
|
await _broker.Received(1).PlaceOrderAsync(
|
||||||
|
Arg.Is<OrderRequest>(o => o.Type == OrderType.Market && o.Quantity == 5),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OrderFails_ReturnsError_AndDoesNotBook()
|
||||||
|
{
|
||||||
|
ArrangeHappyPath();
|
||||||
|
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(OrderResult.Fail("Broker abgelehnt")));
|
||||||
|
|
||||||
|
var result = await CreateSut().ExecuteAsync(BuySignal);
|
||||||
|
|
||||||
|
result.Action.Should().Be("ERROR");
|
||||||
|
result.Reason.Should().Contain("Broker abgelehnt");
|
||||||
|
await _portfolio.DidNotReceive().RecordFillAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TradeSide>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Tests.Trading;
|
||||||
|
|
||||||
|
[Trait("cat", "unit")]
|
||||||
|
public class RiskServiceTests
|
||||||
|
{
|
||||||
|
private readonly RiskService _risk = new();
|
||||||
|
|
||||||
|
// MaxTrade 5 %, MaxModul 20 %, MaxSlippage 5 %
|
||||||
|
private static readonly RiskParameters DefaultParams = new(5m, 20m, 5m);
|
||||||
|
|
||||||
|
private static TradeSignal Buy(decimal? limit = null, decimal? notional = null) => new()
|
||||||
|
{
|
||||||
|
Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT",
|
||||||
|
LimitPrice = limit, SuggestedNotional = notional
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TradeSignal Sell() => new()
|
||||||
|
{
|
||||||
|
Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_SizesByMaxTradePercent()
|
||||||
|
{
|
||||||
|
// NetLiq 10.000 × 5 % = 500 max Nominal; Kurs 100 → 5 Stück
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Buy(), ctx, DefaultParams);
|
||||||
|
|
||||||
|
d.Approved.Should().BeTrue();
|
||||||
|
d.Quantity.Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_UsesSuggestedNotional_WhenSmallerThanMax()
|
||||||
|
{
|
||||||
|
// Wunsch 200 < Max 500; Kurs 100 → 2 Stück
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Buy(notional: 200m), ctx, DefaultParams);
|
||||||
|
|
||||||
|
d.Quantity.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_InvalidPrice_Rejected()
|
||||||
|
{
|
||||||
|
var ctx = new RiskContext { Price = 0m, NetLiquidation = 10_000m };
|
||||||
|
|
||||||
|
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_ZeroAccount_Rejected()
|
||||||
|
{
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 0m };
|
||||||
|
|
||||||
|
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_QuantityBelowOne_Rejected()
|
||||||
|
{
|
||||||
|
// NetLiq 100 × 5 % = 5 max Nominal; Kurs 100 → 0 Stück
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 100m };
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Buy(), ctx, DefaultParams);
|
||||||
|
|
||||||
|
d.Approved.Should().BeFalse();
|
||||||
|
d.Reason.Should().Contain("< 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_ExceedsModuleLimit_Rejected()
|
||||||
|
{
|
||||||
|
// MaxTrade 50 % → 5.000 Nominal, Kurs 100 → 50 Stück = 5.000
|
||||||
|
// Modul-Limit 20 % × 10.000 = 2.000 → abgelehnt
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m };
|
||||||
|
var pars = new RiskParameters(50m, 20m, 5m);
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Buy(), ctx, pars);
|
||||||
|
|
||||||
|
d.Approved.Should().BeFalse();
|
||||||
|
d.Reason.Should().Contain("Modul-Limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_ExistingExposureCountsTowardModuleLimit()
|
||||||
|
{
|
||||||
|
// Kurs 100, Max 5 % → 5 Stück (500). Bereits 1.700 Exposure.
|
||||||
|
// Projektiert 2.200 > Limit 2.000 → abgelehnt.
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ModuleExposure = 1_700m };
|
||||||
|
|
||||||
|
_risk.Evaluate(Buy(), ctx, DefaultParams).Approved.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_LimitOrder_SlippageTooHigh_Rejected()
|
||||||
|
{
|
||||||
|
// Limit 100, Kurs 110 → 10 % > 5 %
|
||||||
|
var ctx = new RiskContext { Price = 110m, NetLiquidation = 10_000m };
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams);
|
||||||
|
|
||||||
|
d.Approved.Should().BeFalse();
|
||||||
|
d.Reason.Should().Contain("Slippage");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buy_LimitOrder_SlippageWithinTolerance_Approved()
|
||||||
|
{
|
||||||
|
// Limit 100, Kurs 104 → 4 % < 5 %
|
||||||
|
var ctx = new RiskContext { Price = 104m, NetLiquidation = 10_000m };
|
||||||
|
|
||||||
|
_risk.Evaluate(Buy(limit: 100m), ctx, DefaultParams).Approved.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sell_WithPosition_ClosesQuantity()
|
||||||
|
{
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 12 };
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Sell(), ctx, DefaultParams);
|
||||||
|
|
||||||
|
d.Approved.Should().BeTrue();
|
||||||
|
d.Quantity.Should().Be(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sell_WithoutPosition_Rejected()
|
||||||
|
{
|
||||||
|
var ctx = new RiskContext { Price = 100m, NetLiquidation = 10_000m, ExistingQuantity = 0 };
|
||||||
|
|
||||||
|
var d = _risk.Evaluate(Sell(), ctx, DefaultParams);
|
||||||
|
|
||||||
|
d.Approved.Should().BeFalse();
|
||||||
|
d.Reason.Should().Contain("Keine Position");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,14 @@ internal static class Program
|
|||||||
services.AddSingleton<TradeHistoryService>();
|
services.AddSingleton<TradeHistoryService>();
|
||||||
services.AddSingleton<AIModelService>();
|
services.AddSingleton<AIModelService>();
|
||||||
|
|
||||||
|
// Core: Trading-Kern
|
||||||
|
services.AddSingleton<IRiskService, RiskService>();
|
||||||
|
services.AddSingleton<IPortfolioService, PortfolioService>();
|
||||||
|
services.AddSingleton<IExecutionService, ExecutionService>();
|
||||||
|
// Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter
|
||||||
|
// angebunden und gegen den Paper-Gateway verifiziert ist.
|
||||||
|
services.AddSingleton<IBrokerClient, NullBrokerClient>();
|
||||||
|
|
||||||
// Core: Built-In Worker
|
// Core: Built-In Worker
|
||||||
services.AddSingleton<BackupWorker>();
|
services.AddSingleton<BackupWorker>();
|
||||||
services.AddSingleton<WebserverService>();
|
services.AddSingleton<WebserverService>();
|
||||||
|
|||||||
@@ -117,14 +117,15 @@ WinForms selbst wird **nicht** unit-getestet – Logik in Services/Manager halte
|
|||||||
- [x] `WindowManager` in DI; Launcher schließt Modul-Fenster beim Beenden
|
- [x] `WindowManager` in DI; Launcher schließt Modul-Fenster beim Beenden
|
||||||
- [x] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert
|
- [x] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert
|
||||||
|
|
||||||
### Phase 3 – Trading-Kern (Core)
|
### Phase 3 – Trading-Kern (Core) ✅
|
||||||
- [ ] `Core/Trading/IIbkrClient.cs` (+ Adapter auf `IBKRGatewayService`)
|
- [x] `Core/Trading/TradingModels.cs` (Signal, Order, RiskContext/Decision, Account, Position, Quote)
|
||||||
- [ ] `Core/Trading/IOrderService.cs` + Implementierung (Market/Limit, Paper+Live)
|
- [x] `Core/Trading/IBrokerClient.cs` + **`NullBrokerClient`** (sicherer Default: handelt nie)
|
||||||
- [ ] `Core/Trading/PortfolioService.cs` + Migrationen `core_position`, `core_trade`, `core_account_snapshot`
|
- [x] `Core/Trading/PortfolioService.cs` (+ `IPortfolioService`) + Migration `core_position` (nutzt vorhandene `core_trade_history`/`core_budget`)
|
||||||
- [ ] `Core/Trading/RiskService.cs` (Sizing, Limits, Slippage, Profit-Target, globaler Pause-Schalter)
|
- [x] `Core/Trading/RiskService.cs` (+ `IRiskService`): Sizing, Modul-Limit, Slippage
|
||||||
- [ ] `Core/Trading/ExecutionService.cs` (`TradeSignal` → Risiko → Order → Buchung)
|
- [x] `Core/Trading/ExecutionService.cs` (+ `IExecutionService`): Signal → Kurs → Konto → Risiko → Order → Buchung
|
||||||
- [ ] `TradingSettings` in `AppSettings` (Mode Paper/Live, Risikoparameter)
|
- [x] `TradingSettings` in `AppSettings` (Mode Paper/Live, TradingEnabled, Risikoparameter)
|
||||||
- [ ] Tests: `RiskService`, `ExecutionService` (voll gemockt)
|
- [x] Tests: `RiskService` (11), `ExecutionService` (6, voll gemockt) → **38/38 grün**
|
||||||
|
- [ ] **Offen (bewusst):** echter `IbkrBrokerClient` (Quote/Konto/Order gegen Client-Portal-Gateway) — manuelle Verifikation gegen Paper-Account
|
||||||
|
|
||||||
### Phase 4 – CongressTrading als vollständige Strategie
|
### Phase 4 – CongressTrading als vollständige Strategie
|
||||||
- [ ] `CongressTradingStrategy`: neue Scrape-Trades → `TradeSignal` an `ExecutionService`
|
- [ ] `CongressTradingStrategy`: neue Scrape-Trades → `TradeSignal` an `ExecutionService`
|
||||||
|
|||||||
Reference in New Issue
Block a user