diff --git a/Core/Database/Migrations/CoreMigrations.cs b/Core/Database/Migrations/CoreMigrations.cs index 23cb85b..40698f1 100644 --- a/Core/Database/Migrations/CoreMigrations.cs +++ b/Core/Database/Migrations/CoreMigrations.cs @@ -24,6 +24,7 @@ public class CoreMigrations await CreateCoreWorkerLogAsync(); await CreateCoreTradeHistoryAsync(); await CreateCoreBudgetAsync(); + await CreateCorePositionAsync(); _logger.Info("Core", "Core-Migrationen abgeschlossen."); } @@ -76,4 +77,15 @@ public class CoreMigrations `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) 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;"); } diff --git a/Core/Settings/AppSettings.cs b/Core/Settings/AppSettings.cs index d2b9fe3..6912493 100644 --- a/Core/Settings/AppSettings.cs +++ b/Core/Settings/AppSettings.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using IBKRTrader.Core.Trading; namespace IBKRTrader.Core.Settings; @@ -171,6 +172,48 @@ public class WorkerSettings 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; + + /// Parst den Modus in das Enum (Fallback: Paper). + public TradingMode ParsedMode => + Enum.TryParse(Mode, true, out var m) ? m : TradingMode.Paper; + + public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}"; +} + // ─── Root ──────────────────────────────────────────────────────────────────── public class AppSettings @@ -204,4 +247,9 @@ public class AppSettings [DisplayName("Worker-Einstellungen")] [Description("Zeitpläne der einzelnen Core-Worker")] public WorkerSettings WorkerSettings { get; set; } = new(); + + [Category("Trading")] + [DisplayName("Trading")] + [Description("Handelsmodus und Risiko-Parameter")] + public TradingSettings Trading { get; set; } = new(); } diff --git a/Core/Trading/ExecutionService.cs b/Core/Trading/ExecutionService.cs new file mode 100644 index 0000000..6d7c7e4 --- /dev/null +++ b/Core/Trading/ExecutionService.cs @@ -0,0 +1,96 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.Core.Trading; + +/// +/// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung. +/// Kennt kein Modul – Module rufen nur mit ihrem Signal auf. +/// +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 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; + } +} diff --git a/Core/Trading/IBrokerClient.cs b/Core/Trading/IBrokerClient.cs new file mode 100644 index 0000000..982a5c6 --- /dev/null +++ b/Core/Trading/IBrokerClient.cs @@ -0,0 +1,17 @@ +namespace IBKRTrader.Core.Trading; + +/// +/// Broker-Abstraktion (Kurse, Konto, Order-Ausführung). +/// Externer Seam: in Tests gemockt, produktiv von einem IBKR-Adapter implementiert. +/// +public interface IBrokerClient +{ + /// Aktuelle Kurs-Momentaufnahme oder null, wenn nicht verfügbar. + Task GetQuoteAsync(string symbol, CancellationToken ct = default); + + /// Kontostand-Momentaufnahme. + Task GetAccountStateAsync(CancellationToken ct = default); + + /// Platziert eine Order und gibt das Ergebnis zurück. + Task PlaceOrderAsync(OrderRequest request, CancellationToken ct = default); +} diff --git a/Core/Trading/IExecutionService.cs b/Core/Trading/IExecutionService.cs new file mode 100644 index 0000000..8fd1a1b --- /dev/null +++ b/Core/Trading/IExecutionService.cs @@ -0,0 +1,10 @@ +namespace IBKRTrader.Core.Trading; + +/// +/// Zentrale Ausführungs-Pipeline. Module übergeben ihre e hier; +/// der Core prüft Risiko, führt aus und verbucht. +/// +public interface IExecutionService +{ + Task ExecuteAsync(TradeSignal signal, CancellationToken ct = default); +} diff --git a/Core/Trading/IPortfolioService.cs b/Core/Trading/IPortfolioService.cs new file mode 100644 index 0000000..f1544c4 --- /dev/null +++ b/Core/Trading/IPortfolioService.cs @@ -0,0 +1,22 @@ +namespace IBKRTrader.Core.Trading; + +/// +/// Buchführung über offene Positionen und Ausführungen (core_position, core_trade_history). +/// Externer Seam gegenüber der Datenbank: in Tests gemockt. +/// +public interface IPortfolioService +{ + /// Summe des offenen Nominalwerts eines Moduls. + Task GetModuleExposureAsync(string module, CancellationToken ct = default); + + /// Gehaltene Stückzahl eines Moduls für ein Symbol (0, wenn keine Position). + Task GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default); + + /// Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie. + Task RecordFillAsync( + string module, string symbol, TradeSide side, + int quantity, decimal price, string? orderId, CancellationToken ct = default); + + /// Alle offenen Positionen eines Moduls. + Task> GetPositionsAsync(string module, CancellationToken ct = default); +} diff --git a/Core/Trading/IRiskService.cs b/Core/Trading/IRiskService.cs new file mode 100644 index 0000000..458cb12 --- /dev/null +++ b/Core/Trading/IRiskService.cs @@ -0,0 +1,7 @@ +namespace IBKRTrader.Core.Trading; + +/// Bewertet ein Signal gegen die Risiko-Parameter und liefert die Stückzahl. +public interface IRiskService +{ + RiskDecision Evaluate(TradeSignal signal, RiskContext context, RiskParameters risk); +} diff --git a/Core/Trading/NullBrokerClient.cs b/Core/Trading/NullBrokerClient.cs new file mode 100644 index 0000000..f9ff32c --- /dev/null +++ b/Core/Trading/NullBrokerClient.cs @@ -0,0 +1,29 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.Trading; + +/// +/// 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. +/// +public sealed class NullBrokerClient : IBrokerClient +{ + private readonly LoggingService _logger; + + public NullBrokerClient(LoggingService logger) => _logger = logger; + + public Task GetQuoteAsync(string symbol, CancellationToken ct = default) + => Task.FromResult(null); + + public Task GetAccountStateAsync(CancellationToken ct = default) + => Task.FromResult(new AccountState(0m, 0m)); + + public Task 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).")); + } +} diff --git a/Core/Trading/PortfolioService.cs b/Core/Trading/PortfolioService.cs new file mode 100644 index 0000000..12f610c --- /dev/null +++ b/Core/Trading/PortfolioService.cs @@ -0,0 +1,110 @@ +using IBKRTrader.Core.Budget; +using IBKRTrader.Core.Database; +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Core.Trading; + +/// +/// DB-gestützte Buchführung über offene Positionen (core_position), +/// Trade-Historie (core_trade_history) und Budget (core_budget). +/// +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 GetModuleExposureAsync(string module, CancellationToken ct = default) + { + var sum = await _db.ExecuteScalarAsync( + "SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module", + new { module }); + return sum ?? 0m; + } + + public async Task GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default) + { + var row = await _db.QueryFirstOrDefaultAsync( + "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> GetPositionsAsync(string module, CancellationToken ct = default) + { + var rows = await _db.QueryAsync( + "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( + "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 }); +} diff --git a/Core/Trading/RiskService.cs b/Core/Trading/RiskService.cs new file mode 100644 index 0000000..cc42b3e --- /dev/null +++ b/Core/Trading/RiskService.cs @@ -0,0 +1,74 @@ +namespace IBKRTrader.Core.Trading; + +/// +/// 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. +/// +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; + } +} diff --git a/Core/Trading/TradingModels.cs b/Core/Trading/TradingModels.cs new file mode 100644 index 0000000..044fb86 --- /dev/null +++ b/Core/Trading/TradingModels.cs @@ -0,0 +1,124 @@ +namespace IBKRTrader.Core.Trading; + +/// Kauf oder Verkauf. +public enum TradeSide { Buy, Sell } + +/// Order-Typ. +public enum OrderType { Market, Limit } + +/// Handelsmodus – Paper-Account (Test) oder Live. +public enum TradingMode { Paper, Live } + +/// +/// Signal, das ein Modul an den übergibt. +/// Das Modul liefert nur die Absicht – Sizing, Risiko und Ausführung macht der Core. +/// +public sealed record TradeSignal +{ + /// Ticker-Symbol (z. B. "AAPL"). + public required string Symbol { get; init; } + + /// Kauf oder Verkauf. + public required TradeSide Side { get; init; } + + /// Kürzel des auslösenden Moduls (z. B. "CT"). + public required string SourceModule { get; init; } + + /// Begründung des Signals (für Logging/Buchführung). + public string Reason { get; init; } = ""; + + /// Optionaler Limit-Preis. null = Market-Order. + public decimal? LimitPrice { get; init; } + + /// Optionaler Nominalwert-Wunsch; sonst greift das Risiko-Sizing. + public decimal? SuggestedNotional { get; init; } +} + +/// Konkrete Order-Anforderung an den Broker. +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; } +} + +/// Ergebnis einer Order-Platzierung. +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 }; +} + +/// Momentaufnahme eines Kurses. +public sealed record Quote(string Symbol, decimal Last, decimal Bid, decimal Ask); + +/// Kontostand-Momentaufnahme des Brokers. +public sealed record AccountState(decimal NetLiquidation, decimal AvailableFunds); + +/// Offene Position eines Moduls. +public sealed record Position(string Module, string Symbol, int Quantity, decimal AvgPrice) +{ + public decimal Notional => Quantity * AvgPrice; +} + +/// Kontext für die Risikobewertung eines Signals. +public sealed record RiskContext +{ + /// Aktueller Kurs des Symbols. + public required decimal Price { get; init; } + + /// Netto-Liquidationswert des Kontos. + public required decimal NetLiquidation { get; init; } + + /// Aktuell vom Modul gehaltener Nominalwert (Summe offener Positionen). + public decimal ModuleExposure { get; init; } + + /// Bereits gehaltene Stückzahl für das Signal-Symbol. + public int ExistingQuantity { get; init; } +} + +/// Aus den Settings abgeleitete Risiko-Parameter. +public sealed record RiskParameters( + decimal MaxTradePercent, + decimal MaxPositionPercentPerModule, + decimal MaxSlippagePercent); + +/// Entscheidung der Risikoprüfung. +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 }; +} + +/// Ergebnis einer Signal-Ausführung durch den . +public sealed record ExecutionResult +{ + /// "EXECUTE", "SKIP" oder "ERROR". + 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 }; +} diff --git a/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs b/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs new file mode 100644 index 0000000..657361c --- /dev/null +++ b/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs @@ -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(); + private readonly IRiskService _risk = Substitute.For(); + private readonly IPortfolioService _portfolio = Substitute.For(); + 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(), Arg.Any()) + .Returns(Task.FromResult(new Quote("AAPL", 100m, 99m, 101m))); + _broker.GetAccountStateAsync(Arg.Any()) + .Returns(Task.FromResult(new AccountState(10_000m, 10_000m))); + _portfolio.GetModuleExposureAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(0m)); + _portfolio.GetPositionQuantityAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(0)); + _risk.Evaluate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RiskDecision.Approve(5)); + _broker.PlaceOrderAsync(Arg.Any(), Arg.Any()) + .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(), Arg.Any()); + } + + [Fact] + public async Task NoQuote_Skips() + { + EnableTrading(); + _broker.GetQuoteAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var result = await CreateSut().ExecuteAsync(BuySignal); + + result.Action.Should().Be("SKIP"); + await _broker.DidNotReceive().PlaceOrderAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RiskRejects_Skips_WithReason() + { + EnableTrading(); + _broker.GetQuoteAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new Quote("AAPL", 100m, 99m, 101m))); + _broker.GetAccountStateAsync(Arg.Any()) + .Returns(Task.FromResult(new AccountState(10_000m, 10_000m))); + _risk.Evaluate(Arg.Any(), Arg.Any(), Arg.Any()) + .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(), Arg.Any()); + } + + [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()); + } + + [Fact] + public async Task HappyPath_MarketOrder_WhenNoLimitPrice() + { + ArrangeHappyPath(); + + await CreateSut().ExecuteAsync(BuySignal); + + await _broker.Received(1).PlaceOrderAsync( + Arg.Is(o => o.Type == OrderType.Market && o.Quantity == 5), + Arg.Any()); + } + + [Fact] + public async Task OrderFails_ReturnsError_AndDoesNotBook() + { + ArrangeHappyPath(); + _broker.PlaceOrderAsync(Arg.Any(), Arg.Any()) + .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(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/IBKRTrader.Tests/Trading/RiskServiceTests.cs b/IBKRTrader.Tests/Trading/RiskServiceTests.cs new file mode 100644 index 0000000..9d82e2f --- /dev/null +++ b/IBKRTrader.Tests/Trading/RiskServiceTests.cs @@ -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"); + } +} diff --git a/Program.cs b/Program.cs index 30dd263..b0047dd 100644 --- a/Program.cs +++ b/Program.cs @@ -47,6 +47,14 @@ internal static class Program services.AddSingleton(); services.AddSingleton(); + // Core: Trading-Kern + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter + // angebunden und gegen den Paper-Gateway verifiziert ist. + services.AddSingleton(); + // Core: Built-In Worker services.AddSingleton(); services.AddSingleton(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c52fb8..392fb1f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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] Tests: `WindowManager` (6) → **21/21 grün**; Launcher-Start verifiziert -### Phase 3 – Trading-Kern (Core) -- [ ] `Core/Trading/IIbkrClient.cs` (+ Adapter auf `IBKRGatewayService`) -- [ ] `Core/Trading/IOrderService.cs` + Implementierung (Market/Limit, Paper+Live) -- [ ] `Core/Trading/PortfolioService.cs` + Migrationen `core_position`, `core_trade`, `core_account_snapshot` -- [ ] `Core/Trading/RiskService.cs` (Sizing, Limits, Slippage, Profit-Target, globaler Pause-Schalter) -- [ ] `Core/Trading/ExecutionService.cs` (`TradeSignal` → Risiko → Order → Buchung) -- [ ] `TradingSettings` in `AppSettings` (Mode Paper/Live, Risikoparameter) -- [ ] Tests: `RiskService`, `ExecutionService` (voll gemockt) +### Phase 3 – Trading-Kern (Core) ✅ +- [x] `Core/Trading/TradingModels.cs` (Signal, Order, RiskContext/Decision, Account, Position, Quote) +- [x] `Core/Trading/IBrokerClient.cs` + **`NullBrokerClient`** (sicherer Default: handelt nie) +- [x] `Core/Trading/PortfolioService.cs` (+ `IPortfolioService`) + Migration `core_position` (nutzt vorhandene `core_trade_history`/`core_budget`) +- [x] `Core/Trading/RiskService.cs` (+ `IRiskService`): Sizing, Modul-Limit, Slippage +- [x] `Core/Trading/ExecutionService.cs` (+ `IExecutionService`): Signal → Kurs → Konto → Risiko → Order → Buchung +- [x] `TradingSettings` in `AppSettings` (Mode Paper/Live, TradingEnabled, Risikoparameter) +- [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 - [ ] `CongressTradingStrategy`: neue Scrape-Trades → `TradeSignal` an `ExecutionService`