diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50128ec..801df07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -107,9 +107,12 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin - [x] `LauncherForm` startet/stoppt keine Worker mehr; Build + 43/43 Tests + smoke-ui + App-Start grün - Hinweis: Trading-Kern (Risk/Execution/Portfolio/Broker) wurde bereits in Phase 3 gebaut und in R3 auf EF gehoben -### R5 – CongressTrading als vollständige Strategie -- [ ] Scraper/Jobs → `IHostedService`; Signal → `ExecutionService` -- [ ] Modul-View (Tabs) via `RegisterUi` +### R5 – CongressTrading als vollständige Strategie ✅ +- [x] `CongressTradingStrategy`: neuer Scrape-Trade → `TradeSignal` (buy/sell-Mapping) → Core-`IExecutionService` +- [x] `CongressScrapeWorker` ruft die Strategie je neuem Trade auf (per try/catch isoliert) +- [x] Modul-Fenster zeigt offene Positionen (`IPortfolioService.GetPositionsAsync("CT")`) +- [x] **8 neue Tests** (Signal-Mapping/Ausführung, gemockter ExecutionService) → 51/51 grün +- Hinweis: Handel bleibt durch Trading-Gate (`TradingEnabled=false`) + `NullBrokerClient` sicher aus, bis echter Broker + Freigabe ### R6 – Sicherheit + Config-Härtung - [ ] `SecretProtection` (Master-Key, AES-256-GCM at-rest), TLS-Warnung diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index b0fc0d5..2b44ce9 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -2,6 +2,7 @@ using IBKRTrader.Core.Configuration; using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Persistence.Ef; @@ -34,6 +35,7 @@ public sealed class CongressTradingModule : IModule services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -57,6 +59,7 @@ public sealed class CongressTradingModule : IModule CreateForm = () => new CongressTradingForm( services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService()) }); } diff --git a/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs b/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs new file mode 100644 index 0000000..5c74125 --- /dev/null +++ b/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs @@ -0,0 +1,50 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Trading; +using IBKRTrader.Modules.CongressTrading.Models; + +namespace IBKRTrader.Modules.CongressTrading.Strategy; + +/// +/// Strategie des CongressTrading-Moduls: wandelt einen neu gescrapten Kongress-Trade in ein +/// um und übergibt es dem Core-. +/// Risiko, Kurs, Order und Buchung macht der Core – das Modul liefert nur die Absicht. +/// +public class CongressTradingStrategy +{ + private readonly IExecutionService _execution; + private readonly LoggingService _logger; + + public CongressTradingStrategy(IExecutionService execution, LoggingService logger) + { + _execution = execution; + _logger = logger; + } + + /// Verarbeitet einen neuen Trade: erzeugt ggf. ein Signal und führt es aus. + public async Task HandleNewTradeAsync(CongressTrade trade, CancellationToken ct = default) + { + var side = MapSide(trade.TradeType); + if (side is null || string.IsNullOrWhiteSpace(trade.Ticker)) + return; // z. B. "exchange"/"receive" oder kein Ticker → kein Handel + + var signal = new TradeSignal + { + Symbol = trade.Ticker, + Side = side.Value, + SourceModule = CongressTradingModule.LogTag, + Reason = $"Kongress-{trade.TradeType}: {trade.MemberBioId} / {trade.IssuerName}" + }; + + var result = await _execution.ExecuteAsync(signal, ct); + _logger.Info(CongressTradingModule.LogTag, + $"Signal {signal.Side} {signal.Symbol} → {result.Action}: {result.Reason}"); + } + + /// Mappt den Kongress-Trade-Typ auf eine Handelsrichtung (buy/sell); alles andere = null. + public static TradeSide? MapSide(string? tradeType) => (tradeType ?? "").Trim().ToLowerInvariant() switch + { + "buy" => TradeSide.Buy, + "sell" => TradeSide.Sell, + _ => null + }; +} diff --git a/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs b/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs index 8350d58..3835ee8 100644 --- a/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs +++ b/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs @@ -1,13 +1,13 @@ using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading.Database; namespace IBKRTrader.Modules.CongressTrading.UI; /// -/// Eigenständiges Fenster des CongressTrading-Moduls. -/// Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger. -/// Die vollständige Trade-/Positions-Ansicht folgt in einer späteren Phase. +/// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger +/// und die offenen Positionen des Moduls (aus dem Core-Portfolio). /// public sealed class CongressTradingForm : Form { @@ -15,6 +15,7 @@ public sealed class CongressTradingForm : Form private readonly CongressRepository _repo; private readonly WorkerEngine _engine; + private readonly IPortfolioService _portfolio; private readonly LoggingService _logger; private readonly Label _lblTrades = new() { AutoSize = true, Location = new Point(20, 70) }; @@ -23,11 +24,22 @@ public sealed class CongressTradingForm : Form private readonly Button _btnScrape = new() { Text = "Scrape jetzt", Location = new Point(170, 140), Width = 140 }; private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText }; - public CongressTradingForm(CongressRepository repo, WorkerEngine engine, LoggingService logger) + private readonly DataGridView _positions = new() { - _repo = repo; - _engine = engine; - _logger = logger; + Location = new Point(20, 250), + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + ReadOnly = true, + AllowUserToAddRows = false, + RowHeadersVisible = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill + }; + + public CongressTradingForm(CongressRepository repo, WorkerEngine engine, IPortfolioService portfolio, LoggingService logger) + { + _repo = repo; + _engine = engine; + _portfolio = portfolio; + _logger = logger; Text = "Congress Trading [CT]"; Width = 900; @@ -47,14 +59,15 @@ public sealed class CongressTradingForm : Form AutoSize = true }; - var hint = new Label + var posLabel = new Label { - Text = "Vollständige Trade- und Positions-Ansicht folgt in Phase 4.", - Location = new Point(20, 230), - AutoSize = true, - ForeColor = SystemColors.GrayText + Text = "Offene Positionen (Modul CT):", + Location = new Point(20, 225), + AutoSize = true }; + _positions.Size = new Size(ClientSize.Width - 40, ClientSize.Height - 270); + _btnRefresh.Click += async (_, _) => await RefreshStatsAsync(); _btnScrape.Click += async (_, _) => await TriggerScrapeAsync(); @@ -64,7 +77,8 @@ public sealed class CongressTradingForm : Form Controls.Add(_btnRefresh); Controls.Add(_btnScrape); Controls.Add(_lblStatus); - Controls.Add(hint); + Controls.Add(posLabel); + Controls.Add(_positions); } protected override async void OnShown(EventArgs e) @@ -81,6 +95,12 @@ public sealed class CongressTradingForm : Form var members = await _repo.GetMemberCountAsync(); _lblTrades.Text = $"Trades in DB: {trades:N0}"; _lblMembers.Text = $"Mitglieder in DB: {members:N0}"; + + var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag); + _positions.DataSource = positions + .Select(p => new { p.Symbol, Stück = p.Quantity, Ø_Kurs = p.AvgPrice, Wert = p.Notional }) + .ToList(); + _lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}"; } catch (Exception ex) diff --git a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs index 6d5fa02..da28a60 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs @@ -4,6 +4,7 @@ using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Scraper; +using IBKRTrader.Modules.CongressTrading.Strategy; using IBKRTrader.Core.Workers; namespace IBKRTrader.Modules.CongressTrading.Workers; @@ -15,8 +16,9 @@ namespace IBKRTrader.Modules.CongressTrading.Workers; /// public class CongressScrapeWorker : WorkerBase { - private readonly CapitolTradesScraper _scraper; - private readonly CongressRepository _repo; + private readonly CapitolTradesScraper _scraper; + private readonly CongressRepository _repo; + private readonly CongressTradingStrategy _strategy; public override string Name => "CT-ScrapeWorker"; public override string Module => "CT"; @@ -25,15 +27,17 @@ public class CongressScrapeWorker : WorkerBase protected override TimeSpan? Interval => TimeSpan.FromMinutes(30); public CongressScrapeWorker( - LoggingService logger, - IDbContextFactory db, - CapitolTradesScraper scraper, - CongressRepository repo, - SettingsService settings) + LoggingService logger, + IDbContextFactory db, + CapitolTradesScraper scraper, + CongressRepository repo, + CongressTradingStrategy strategy, + SettingsService settings) : base(logger, db) { - _scraper = scraper; - _repo = repo; + _scraper = scraper; + _repo = repo; + _strategy = strategy; } protected override async Task ExecuteAsync(CancellationToken ct) @@ -74,6 +78,10 @@ public class CongressScrapeWorker : WorkerBase Logger.Info(Module, $" + {trade.IssuerName} ({trade.TradeType}) [{trade.Ticker}] von {trade.MemberBioId}"); newTrades++; + + // Strategie: neuer Trade → Signal an den Core-ExecutionService (Trading-Gate/Broker entscheiden). + try { await _strategy.HandleNewTradeAsync(trade, ct); } + catch (Exception ex) { Logger.Error(Module, $"Strategie-Fehler für {trade.Ticker}: {ex.Message}", ex); } } var summary = $"{newTrades} neue Trades, {newMembers} neue Mitglieder"; diff --git a/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs b/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs new file mode 100644 index 0000000..bffbd22 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Trading; +using IBKRTrader.Modules.CongressTrading.Models; +using IBKRTrader.Modules.CongressTrading.Strategy; +using NSubstitute; + +namespace IBKRTrader.Tests.Modules; + +[Trait("cat", "unit")] +public class CongressTradingStrategyTests +{ + private readonly IExecutionService _execution = Substitute.For(); + + private CongressTradingStrategy CreateSut() + { + _execution.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(ExecutionResult.Skip("test"))); + return new CongressTradingStrategy(_execution, new LoggingService()); + } + + private static CongressTrade Trade(string type, string ticker = "AAPL") => + new() { TradeId = "T1", MemberBioId = "W1", IssuerName = "Apple", Ticker = ticker, TradeType = type }; + + [Theory] + [InlineData("buy", TradeSide.Buy)] + [InlineData("BUY", TradeSide.Buy)] + [InlineData("sell", TradeSide.Sell)] + public async Task NewTrade_EmitsSignal_WithMappedSide(string type, TradeSide expected) + { + var sut = CreateSut(); + + await sut.HandleNewTradeAsync(Trade(type)); + + await _execution.Received(1).ExecuteAsync( + Arg.Is(s => s.Side == expected && s.Symbol == "AAPL" && s.SourceModule == "CT"), + Arg.Any()); + } + + [Theory] + [InlineData("exchange")] + [InlineData("receive")] + [InlineData("")] + public async Task NonBuySell_TradeType_DoesNotExecute(string type) + { + var sut = CreateSut(); + + await sut.HandleNewTradeAsync(Trade(type)); + + await _execution.DidNotReceive().ExecuteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EmptyTicker_DoesNotExecute() + { + var sut = CreateSut(); + + await sut.HandleNewTradeAsync(Trade("buy", ticker: "")); + + await _execution.DidNotReceive().ExecuteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void MapSide_IsCaseInsensitive_AndIgnoresUnknown() + { + CongressTradingStrategy.MapSide("Buy").Should().Be(TradeSide.Buy); + CongressTradingStrategy.MapSide("SELL").Should().Be(TradeSide.Sell); + CongressTradingStrategy.MapSide("exchange").Should().BeNull(); + CongressTradingStrategy.MapSide(null).Should().BeNull(); + } +}