From 7e3791b82db16a578140daf77a3258b58fcf0375 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 28 Jul 2026 10:45:54 +0200 Subject: [PATCH] =?UTF-8?q?@=20R3=20Slice=202:=20Trading-Buchf=C3=BChrung?= =?UTF-8?q?=20auf=20EF=20Core=20(+=20InMemory-Tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BudgetService, TradeHistoryService, PortfolioService von Dapper/DatabaseService auf IDbContextFactory (EF Core) umgestellt - Positionen/Budget/Trade-Historie ueber CoreDbContext (core_position/core_budget/core_trade_history) - Tests: EF-InMemory-Provider im Testprojekt; 5 PortfolioService-Tests (Buy/Sell/Avg/Exposure/Isolation) - 35/35 Tests + Build + smoke-ui gruen; WorkerBase-Log/Modul/Dapper-Entfernung folgen Co-Authored-By: Claude Opus 4.8 @ --- docs/ARCHITECTURE.md | 2 +- src/IBKRTrader.Core/Budget/BudgetService.cs | 52 +++++---- .../Trading/PortfolioService.cs | 110 ++++++++---------- .../Trading/TradeHistoryService.cs | 59 +++++----- .../IBKRTrader.Tests/IBKRTrader.Tests.csproj | 1 + .../Trading/PortfolioServiceTests.cs | 99 ++++++++++++++++ 6 files changed, 210 insertions(+), 113 deletions(-) create mode 100644 tests/IBKRTrader.Tests/Trading/PortfolioServiceTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f75804..d663038 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,7 +93,7 @@ App legt keine Tabellen zur Laufzeit an. Server: **MariaDB 11.8.6** (via `--db-v Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettings.Local.json`. - [x] `--db-version`-Diagnose (Serverversion für den EF-Pin) - [x] **Slice 1:** `Configuration/DatabaseOptions` + `DatabaseServerVersion`-Pin (MariaDB 11.8.6); `AddCorePersistence` (`AddDbContextFactory`) + `CoreDbContext` + Entities (core_position, core_trade_history, core_budget, core_worker_log, core_settings) + Design-Time-Factory; **EF-Migration `InitialCore` erzeugt** -- [ ] **Slice 2:** Consumer umstellen: `PortfolioService`, `BudgetService`, `TradeHistoryService`, `WorkerBase`-Log +- [x] **Slice 2:** `PortfolioService`, `BudgetService`, `TradeHistoryService` auf EF (`IDbContextFactory`) umgestellt; **5 EF-InMemory-Unit-Tests** (Buchführung real verifiziert). WorkerBase-Log folgt in Slice 4. - [ ] **Slice 3:** Modul-DbContext (ct_) im CongressTrading-Projekt + Umstellung - [ ] **Slice 4:** Dapper + `DatabaseService` + manuelle Migrationen (CoreMigrations/IBKRMigrations/CongressMigrations) entfernen; IBKR-Marktdaten auf EF - Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`) diff --git a/src/IBKRTrader.Core/Budget/BudgetService.cs b/src/IBKRTrader.Core/Budget/BudgetService.cs index 20b4cbb..81938ad 100644 --- a/src/IBKRTrader.Core/Budget/BudgetService.cs +++ b/src/IBKRTrader.Core/Budget/BudgetService.cs @@ -1,48 +1,54 @@ -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Core.Budget; /// -/// Verwaltet das Budget pro Modul. -/// Liest/schreibt core_budget. +/// Verwaltet das Budget pro Modul (Tabelle core_budget) via EF Core. /// public class BudgetService { - private readonly DatabaseService _db; - private readonly LoggingService _logger; + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; - public BudgetService(DatabaseService db, LoggingService logger) + public BudgetService(IDbContextFactory dbf, LoggingService logger) { - _db = db; + _dbf = dbf; _logger = logger; } public async Task GetAvailableBudgetAsync(string module) { - var row = await _db.QueryFirstOrDefaultAsync( - "SELECT total_budget, used_budget FROM `core_budget` WHERE module = @module", - new { module }); - - if (row == null) return 0; - return (decimal)row.total_budget - (decimal)row.used_budget; + await using var db = await _dbf.CreateDbContextAsync(); + var b = await db.Budgets.FindAsync(module); + return b is null ? 0m : b.TotalBudget - b.UsedBudget; } public async Task ReserveBudgetAsync(string module, decimal amount) { - await _db.ExecuteAsync( - @"INSERT INTO `core_budget` (module, used_budget) - VALUES (@module, @amount) - ON DUPLICATE KEY UPDATE used_budget = used_budget + @amount", - new { module, amount }); + await using var db = await _dbf.CreateDbContextAsync(); + var b = await db.Budgets.FindAsync(module); + if (b is null) + { + db.Budgets.Add(new CoreBudget { Module = module, UsedBudget = amount, UpdatedAt = DateTime.UtcNow }); + } + else + { + b.UsedBudget += amount; + b.UpdatedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(); } public async Task ReleaseBudgetAsync(string module, decimal amount) { - await _db.ExecuteAsync( - @"UPDATE `core_budget` - SET used_budget = GREATEST(0, used_budget - @amount) - WHERE module = @module", - new { module, amount }); + await using var db = await _dbf.CreateDbContextAsync(); + var b = await db.Budgets.FindAsync(module); + if (b is null) return; + b.UsedBudget = Math.Max(0m, b.UsedBudget - amount); + b.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); } } diff --git a/src/IBKRTrader.Core/Trading/PortfolioService.cs b/src/IBKRTrader.Core/Trading/PortfolioService.cs index 12f610c..a132bd9 100644 --- a/src/IBKRTrader.Core/Trading/PortfolioService.cs +++ b/src/IBKRTrader.Core/Trading/PortfolioService.cs @@ -1,62 +1,55 @@ using IBKRTrader.Core.Budget; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Core.Trading; /// -/// DB-gestützte Buchführung über offene Positionen (core_position), -/// Trade-Historie (core_trade_history) und Budget (core_budget). +/// EF-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; + private readonly IDbContextFactory _dbf; + private readonly TradeHistoryService _history; + private readonly BudgetService _budget; + private readonly LoggingService _logger; public PortfolioService( - DatabaseService db, + IDbContextFactory dbf, TradeHistoryService history, BudgetService budget, LoggingService logger) { - _db = db; + _dbf = dbf; _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; + await using var db = await _dbf.CreateDbContextAsync(ct); + var positions = await db.Positions.Where(p => p.Module == module).ToListAsync(ct); + return positions.Sum(p => p.Quantity * p.AvgPrice); } 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; + await using var db = await _dbf.CreateDbContextAsync(ct); + var pos = await db.Positions.FindAsync([module, symbol], ct); + return pos?.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(); + await using var db = await _dbf.CreateDbContextAsync(ct); + var rows = await db.Positions + .Where(p => p.Module == module && p.Quantity > 0) + .ToListAsync(ct); + return rows.Select(p => new Position(p.Module, p.Symbol, p.Quantity, p.AvgPrice)).ToList(); } public async Task RecordFillAsync( @@ -68,43 +61,36 @@ public sealed class PortfolioService : IPortfolioService 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) + await using (var db = await _dbf.CreateDbContextAsync(ct)) { - 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); + var pos = await db.Positions.FindAsync([module, symbol], ct); + var oldQty = pos?.Quantity ?? 0; + var oldAvg = pos?.AvgPrice ?? 0m; + + if (side == TradeSide.Buy) + { + var newQty = oldQty + quantity; + var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price; + if (pos is null) + db.Positions.Add(new CorePosition { Module = module, Symbol = symbol, Quantity = newQty, AvgPrice = newAvg, UpdatedAt = DateTime.UtcNow }); + else + (pos.Quantity, pos.AvgPrice, pos.UpdatedAt) = (newQty, newAvg, DateTime.UtcNow); + } else - await UpsertPositionAsync(module, symbol, newQty, oldAvg); - await _budget.ReleaseBudgetAsync(module, quantity * price); + { + var newQty = oldQty - quantity; + if (pos is not null) + { + if (newQty <= 0) db.Positions.Remove(pos); + else (pos.Quantity, pos.UpdatedAt) = (newQty, DateTime.UtcNow); + } + } + await db.SaveChangesAsync(ct); } + if (side == TradeSide.Buy) await _budget.ReserveBudgetAsync(module, quantity * price); + else 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/src/IBKRTrader.Core/Trading/TradeHistoryService.cs b/src/IBKRTrader.Core/Trading/TradeHistoryService.cs index 1043112..abd96fe 100644 --- a/src/IBKRTrader.Core/Trading/TradeHistoryService.cs +++ b/src/IBKRTrader.Core/Trading/TradeHistoryService.cs @@ -1,17 +1,19 @@ -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Core.Trading; -/// Verwaltet core_trade_history – Lesen und Schreiben von Trade-Einträgen. +/// Verwaltet core_trade_history – Lesen und Schreiben von Trade-Einträgen (EF Core). public class TradeHistoryService { - private readonly DatabaseService _db; - private readonly LoggingService _logger; + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; - public TradeHistoryService(DatabaseService db, LoggingService logger) + public TradeHistoryService(IDbContextFactory dbf, LoggingService logger) { - _db = db; + _dbf = dbf; _logger = logger; } @@ -19,29 +21,32 @@ public class TradeHistoryService string module, string symbol, string action, decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null) { - await _db.ExecuteAsync(@" - INSERT INTO `core_trade_history` - (module, symbol, action, quantity, price, total_value, traded_at, ibkr_order_id, status, notes) - VALUES - (@module, @symbol, @action, @qty, @price, @total, @now, @orderId, 'Executed', @notes)", - new - { - module, - symbol, - action, - qty = quantity, - price, - total = quantity * price, - now = DateTime.UtcNow, - orderId = ibkrOrderId, - notes - }); + await using var db = await _dbf.CreateDbContextAsync(); + db.TradeHistory.Add(new CoreTrade + { + Module = module, + Symbol = symbol, + Action = action, + Quantity = quantity, + Price = price, + TotalValue = quantity * price, + TradedAt = DateTime.UtcNow, + IbkrOrderId = ibkrOrderId, + Status = "Executed", + Notes = notes, + CreatedAt = DateTime.UtcNow + }); + await db.SaveChangesAsync(); _logger.Info(module, $"Trade gespeichert: {action} {quantity}x {symbol} @ {price:F2}"); } - public async Task> GetRecentTradesAsync(int limit = 100) - => await _db.QueryAsync( - "SELECT * FROM `core_trade_history` ORDER BY traded_at DESC LIMIT @limit", - new { limit }); + public async Task> GetRecentTradesAsync(int limit = 100) + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.TradeHistory + .OrderByDescending(t => t.TradedAt) + .Take(limit) + .ToListAsync(); + } } diff --git a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj index 4fe2a61..791807c 100644 --- a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj +++ b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/IBKRTrader.Tests/Trading/PortfolioServiceTests.cs b/tests/IBKRTrader.Tests/Trading/PortfolioServiceTests.cs new file mode 100644 index 0000000..f3a740b --- /dev/null +++ b/tests/IBKRTrader.Tests/Trading/PortfolioServiceTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using IBKRTrader.Core.Budget; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Trading; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Tests.Trading; + +/// +/// EF-gestützte Buchführung gegen die EF-InMemory-Datenbank (kein externer DB-Zugriff, +/// deterministisch – zählt als Unit-Test). +/// +[Trait("cat", "unit")] +public class PortfolioServiceTests +{ + private sealed class InMemoryFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + public InMemoryFactory(DbContextOptions options) => _options = options; + public CoreDbContext CreateDbContext() => new(_options); + } + + private static PortfolioService CreateSut(out IDbContextFactory factory) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + factory = new InMemoryFactory(options); + + var logger = new LoggingService(); + var history = new TradeHistoryService(factory, logger); + var budget = new BudgetService(factory, logger); + return new PortfolioService(factory, history, budget, logger); + } + + [Fact] + public async Task Buy_CreatesPosition_AndExposure_AndBudget_AndHistory() + { + var sut = CreateSut(out var factory); + + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 5, 100m, "O1"); + + (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(5); + (await sut.GetModuleExposureAsync("CT")).Should().Be(500m); + + await using var db = await factory.CreateDbContextAsync(); + db.TradeHistory.Should().ContainSingle(); + (await db.Budgets.FindAsync("CT"))!.UsedBudget.Should().Be(500m); + } + + [Fact] + public async Task Buy_Twice_AveragesPrice() + { + var sut = CreateSut(out _); + + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 100m, "O1"); + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 120m, "O2"); + + (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(20); + // Durchschnitt: (10*100 + 10*120) / 20 = 110 + var positions = await sut.GetPositionsAsync("CT"); + positions.Single().AvgPrice.Should().Be(110m); + } + + [Fact] + public async Task Sell_Partial_ReducesQuantity() + { + var sut = CreateSut(out _); + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 100m, "O1"); + + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Sell, 4, 130m, "O2"); + + (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(6); + } + + [Fact] + public async Task Sell_Full_RemovesPosition() + { + var sut = CreateSut(out _); + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 10, 100m, "O1"); + + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Sell, 10, 130m, "O2"); + + (await sut.GetPositionQuantityAsync("CT", "AAPL")).Should().Be(0); + (await sut.GetPositionsAsync("CT")).Should().BeEmpty(); + } + + [Fact] + public async Task Exposure_IsIsolatedPerModule() + { + var sut = CreateSut(out _); + await sut.RecordFillAsync("CT", "AAPL", TradeSide.Buy, 5, 100m, "O1"); + await sut.RecordFillAsync("XX", "MSFT", TradeSide.Buy, 2, 200m, "O2"); + + (await sut.GetModuleExposureAsync("CT")).Should().Be(500m); + (await sut.GetModuleExposureAsync("XX")).Should().Be(400m); + } +}