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> @
100 lines
3.5 KiB
C#
100 lines
3.5 KiB
C#
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);
|
||
}
|
||
}
|