Files
Richard 7e3791b82d @
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>
@
2026-07-28 10:45:54 +02:00

100 lines
3.5 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}