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`