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
@@ -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);
}
}