R3 Slice 2: Trading-Buchführung auf EF Core (+ InMemory-Tests)

- BudgetService, TradeHistoryService, PortfolioService von Dapper/DatabaseService auf
  IDbContextFactory<CoreDbContext> (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 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 10:45:54 +02:00
parent 261032f9f9
commit 7e3791b82d
6 changed files with 210 additions and 113 deletions
+1 -1
View File
@@ -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<CoreDbContext>`) 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`)
+28 -22
View File
@@ -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;
/// <summary>
/// Verwaltet das Budget pro Modul.
/// Liest/schreibt core_budget.
/// Verwaltet das Budget pro Modul (Tabelle core_budget) via EF Core.
/// </summary>
public class BudgetService
{
private readonly DatabaseService _db;
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
public BudgetService(DatabaseService db, LoggingService logger)
public BudgetService(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
{
_db = db;
_dbf = dbf;
_logger = logger;
}
public async Task<decimal> GetAvailableBudgetAsync(string module)
{
var row = await _db.QueryFirstOrDefaultAsync<dynamic>(
"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();
}
}
+38 -52
View File
@@ -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;
/// <summary>
/// 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).
/// </summary>
public sealed class PortfolioService : IPortfolioService
{
private readonly DatabaseService _db;
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly TradeHistoryService _history;
private readonly BudgetService _budget;
private readonly LoggingService _logger;
public PortfolioService(
DatabaseService db,
IDbContextFactory<CoreDbContext> 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<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
{
var sum = await _db.ExecuteScalarAsync<decimal?>(
"SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module",
new { module });
return sum ?? 0m;
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<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
{
var row = await _db.QueryFirstOrDefaultAsync<PosDto>(
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
"WHERE module = @module AND symbol = @symbol",
new { module, symbol });
return row?.Quantity ?? 0;
await using var db = await _dbf.CreateDbContextAsync(ct);
var pos = await db.Positions.FindAsync([module, symbol], ct);
return pos?.Quantity ?? 0;
}
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
{
var rows = await _db.QueryAsync<Position>(
"SELECT module AS Module, symbol AS Symbol, quantity AS Quantity, avg_price AS AvgPrice " +
"FROM `core_position` WHERE module = @module AND quantity > 0",
new { module });
return rows.ToList();
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<PosDto>(
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
"WHERE module = @module AND symbol = @symbol",
new { module, symbol });
var oldQty = current?.Quantity ?? 0;
var oldAvg = current?.AvgPrice ?? 0m;
await using (var db = await _dbf.CreateDbContextAsync(ct))
{
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;
await UpsertPositionAsync(module, symbol, newQty, newAvg);
await _budget.ReserveBudgetAsync(module, quantity * 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
{
var newQty = oldQty - quantity;
if (newQty <= 0)
await DeletePositionAsync(module, symbol);
else
await UpsertPositionAsync(module, symbol, newQty, oldAvg);
await _budget.ReleaseBudgetAsync(module, quantity * price);
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 });
}
@@ -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;
/// <summary>Verwaltet core_trade_history Lesen und Schreiben von Trade-Einträgen.</summary>
/// <summary>Verwaltet core_trade_history Lesen und Schreiben von Trade-Einträgen (EF Core).</summary>
public class TradeHistoryService
{
private readonly DatabaseService _db;
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
public TradeHistoryService(DatabaseService db, LoggingService logger)
public TradeHistoryService(IDbContextFactory<CoreDbContext> 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
await using var db = await _dbf.CreateDbContextAsync();
db.TradeHistory.Add(new CoreTrade
{
module,
symbol,
action,
qty = quantity,
price,
total = quantity * price,
now = DateTime.UtcNow,
orderId = ibkrOrderId,
notes
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<IEnumerable<dynamic>> GetRecentTradesAsync(int limit = 100)
=> await _db.QueryAsync<dynamic>(
"SELECT * FROM `core_trade_history` ORDER BY traded_at DESC LIMIT @limit",
new { limit });
public async Task<IReadOnlyList<CoreTrade>> GetRecentTradesAsync(int limit = 100)
{
await using var db = await _dbf.CreateDbContextAsync();
return await db.TradeHistory
.OrderByDescending(t => t.TradedAt)
.Take(limit)
.ToListAsync();
}
}
@@ -18,6 +18,7 @@
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
@@ -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;
/// <summary>
/// EF-gestützte Buchführung gegen die EF-InMemory-Datenbank (kein externer DB-Zugriff,
/// deterministisch zählt als Unit-Test).
/// </summary>
[Trait("cat", "unit")]
public class PortfolioServiceTests
{
private sealed class InMemoryFactory : IDbContextFactory<CoreDbContext>
{
private readonly DbContextOptions<CoreDbContext> _options;
public InMemoryFactory(DbContextOptions<CoreDbContext> options) => _options = options;
public CoreDbContext CreateDbContext() => new(_options);
}
private static PortfolioService CreateSut(out IDbContextFactory<CoreDbContext> factory)
{
var options = new DbContextOptionsBuilder<CoreDbContext>()
.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);
}
}