@
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:
@@ -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`.
|
Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettings.Local.json`.
|
||||||
- [x] `--db-version`-Diagnose (Serverversion für den EF-Pin)
|
- [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**
|
- [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 3:** Modul-DbContext (ct_) im CongressTrading-Projekt + Umstellung
|
||||||
- [ ] **Slice 4:** Dapper + `DatabaseService` + manuelle Migrationen (CoreMigrations/IBKRMigrations/CongressMigrations) entfernen; IBKR-Marktdaten auf EF
|
- [ ] **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`)
|
- Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`)
|
||||||
|
|||||||
@@ -1,48 +1,54 @@
|
|||||||
using IBKRTrader.Core.Database;
|
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Persistence.Ef;
|
||||||
|
using IBKRTrader.Core.Persistence.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Budget;
|
namespace IBKRTrader.Core.Budget;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verwaltet das Budget pro Modul.
|
/// Verwaltet das Budget pro Modul (Tabelle core_budget) via EF Core.
|
||||||
/// Liest/schreibt core_budget.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BudgetService
|
public class BudgetService
|
||||||
{
|
{
|
||||||
private readonly DatabaseService _db;
|
private readonly IDbContextFactory<CoreDbContext> _dbf;
|
||||||
private readonly LoggingService _logger;
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
public BudgetService(DatabaseService db, LoggingService logger)
|
public BudgetService(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_dbf = dbf;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<decimal> GetAvailableBudgetAsync(string module)
|
public async Task<decimal> GetAvailableBudgetAsync(string module)
|
||||||
{
|
{
|
||||||
var row = await _db.QueryFirstOrDefaultAsync<dynamic>(
|
await using var db = await _dbf.CreateDbContextAsync();
|
||||||
"SELECT total_budget, used_budget FROM `core_budget` WHERE module = @module",
|
var b = await db.Budgets.FindAsync(module);
|
||||||
new { module });
|
return b is null ? 0m : b.TotalBudget - b.UsedBudget;
|
||||||
|
|
||||||
if (row == null) return 0;
|
|
||||||
return (decimal)row.total_budget - (decimal)row.used_budget;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReserveBudgetAsync(string module, decimal amount)
|
public async Task ReserveBudgetAsync(string module, decimal amount)
|
||||||
{
|
{
|
||||||
await _db.ExecuteAsync(
|
await using var db = await _dbf.CreateDbContextAsync();
|
||||||
@"INSERT INTO `core_budget` (module, used_budget)
|
var b = await db.Budgets.FindAsync(module);
|
||||||
VALUES (@module, @amount)
|
if (b is null)
|
||||||
ON DUPLICATE KEY UPDATE used_budget = used_budget + @amount",
|
{
|
||||||
new { module, amount });
|
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)
|
public async Task ReleaseBudgetAsync(string module, decimal amount)
|
||||||
{
|
{
|
||||||
await _db.ExecuteAsync(
|
await using var db = await _dbf.CreateDbContextAsync();
|
||||||
@"UPDATE `core_budget`
|
var b = await db.Budgets.FindAsync(module);
|
||||||
SET used_budget = GREATEST(0, used_budget - @amount)
|
if (b is null) return;
|
||||||
WHERE module = @module",
|
b.UsedBudget = Math.Max(0m, b.UsedBudget - amount);
|
||||||
new { module, amount });
|
b.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,55 @@
|
|||||||
using IBKRTrader.Core.Budget;
|
using IBKRTrader.Core.Budget;
|
||||||
using IBKRTrader.Core.Database;
|
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Persistence.Ef;
|
||||||
|
using IBKRTrader.Core.Persistence.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Trading;
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DB-gestützte Buchführung über offene Positionen (core_position),
|
/// EF-gestützte Buchführung über offene Positionen (core_position), Trade-Historie
|
||||||
/// Trade-Historie (core_trade_history) und Budget (core_budget).
|
/// (core_trade_history) und Budget (core_budget).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PortfolioService : IPortfolioService
|
public sealed class PortfolioService : IPortfolioService
|
||||||
{
|
{
|
||||||
private readonly DatabaseService _db;
|
private readonly IDbContextFactory<CoreDbContext> _dbf;
|
||||||
private readonly TradeHistoryService _history;
|
private readonly TradeHistoryService _history;
|
||||||
private readonly BudgetService _budget;
|
private readonly BudgetService _budget;
|
||||||
private readonly LoggingService _logger;
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
public PortfolioService(
|
public PortfolioService(
|
||||||
DatabaseService db,
|
IDbContextFactory<CoreDbContext> dbf,
|
||||||
TradeHistoryService history,
|
TradeHistoryService history,
|
||||||
BudgetService budget,
|
BudgetService budget,
|
||||||
LoggingService logger)
|
LoggingService logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_dbf = dbf;
|
||||||
_history = history;
|
_history = history;
|
||||||
_budget = budget;
|
_budget = budget;
|
||||||
_logger = logger;
|
_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)
|
public async Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var sum = await _db.ExecuteScalarAsync<decimal?>(
|
await using var db = await _dbf.CreateDbContextAsync(ct);
|
||||||
"SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module",
|
var positions = await db.Positions.Where(p => p.Module == module).ToListAsync(ct);
|
||||||
new { module });
|
return positions.Sum(p => p.Quantity * p.AvgPrice);
|
||||||
return sum ?? 0m;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
|
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var row = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
await using var db = await _dbf.CreateDbContextAsync(ct);
|
||||||
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
var pos = await db.Positions.FindAsync([module, symbol], ct);
|
||||||
"WHERE module = @module AND symbol = @symbol",
|
return pos?.Quantity ?? 0;
|
||||||
new { module, symbol });
|
|
||||||
return row?.Quantity ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var rows = await _db.QueryAsync<Position>(
|
await using var db = await _dbf.CreateDbContextAsync(ct);
|
||||||
"SELECT module AS Module, symbol AS Symbol, quantity AS Quantity, avg_price AS AvgPrice " +
|
var rows = await db.Positions
|
||||||
"FROM `core_position` WHERE module = @module AND quantity > 0",
|
.Where(p => p.Module == module && p.Quantity > 0)
|
||||||
new { module });
|
.ToListAsync(ct);
|
||||||
return rows.ToList();
|
return rows.Select(p => new Position(p.Module, p.Symbol, p.Quantity, p.AvgPrice)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RecordFillAsync(
|
public async Task RecordFillAsync(
|
||||||
@@ -68,43 +61,36 @@ public sealed class PortfolioService : IPortfolioService
|
|||||||
var action = side == TradeSide.Buy ? "BUY" : "SELL";
|
var action = side == TradeSide.Buy ? "BUY" : "SELL";
|
||||||
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
|
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
|
||||||
|
|
||||||
var current = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
await using (var db = await _dbf.CreateDbContextAsync(ct))
|
||||||
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
{
|
||||||
"WHERE module = @module AND symbol = @symbol",
|
var pos = await db.Positions.FindAsync([module, symbol], ct);
|
||||||
new { module, symbol });
|
var oldQty = pos?.Quantity ?? 0;
|
||||||
|
var oldAvg = pos?.AvgPrice ?? 0m;
|
||||||
var oldQty = current?.Quantity ?? 0;
|
|
||||||
var oldAvg = current?.AvgPrice ?? 0m;
|
|
||||||
|
|
||||||
if (side == TradeSide.Buy)
|
if (side == TradeSide.Buy)
|
||||||
{
|
{
|
||||||
var newQty = oldQty + quantity;
|
var newQty = oldQty + quantity;
|
||||||
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
|
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
|
||||||
await UpsertPositionAsync(module, symbol, newQty, newAvg);
|
if (pos is null)
|
||||||
await _budget.ReserveBudgetAsync(module, quantity * price);
|
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
|
else
|
||||||
{
|
{
|
||||||
var newQty = oldQty - quantity;
|
var newQty = oldQty - quantity;
|
||||||
if (newQty <= 0)
|
if (pos is not null)
|
||||||
await DeletePositionAsync(module, symbol);
|
{
|
||||||
else
|
if (newQty <= 0) db.Positions.Remove(pos);
|
||||||
await UpsertPositionAsync(module, symbol, newQty, oldAvg);
|
else (pos.Quantity, pos.UpdatedAt) = (newQty, DateTime.UtcNow);
|
||||||
await _budget.ReleaseBudgetAsync(module, quantity * price);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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}");
|
_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.Logging;
|
||||||
|
using IBKRTrader.Core.Persistence.Ef;
|
||||||
|
using IBKRTrader.Core.Persistence.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Trading;
|
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
|
public class TradeHistoryService
|
||||||
{
|
{
|
||||||
private readonly DatabaseService _db;
|
private readonly IDbContextFactory<CoreDbContext> _dbf;
|
||||||
private readonly LoggingService _logger;
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
public TradeHistoryService(DatabaseService db, LoggingService logger)
|
public TradeHistoryService(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_dbf = dbf;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,29 +21,32 @@ public class TradeHistoryService
|
|||||||
string module, string symbol, string action,
|
string module, string symbol, string action,
|
||||||
decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null)
|
decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null)
|
||||||
{
|
{
|
||||||
await _db.ExecuteAsync(@"
|
await using var db = await _dbf.CreateDbContextAsync();
|
||||||
INSERT INTO `core_trade_history`
|
db.TradeHistory.Add(new CoreTrade
|
||||||
(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,
|
Module = module,
|
||||||
symbol,
|
Symbol = symbol,
|
||||||
action,
|
Action = action,
|
||||||
qty = quantity,
|
Quantity = quantity,
|
||||||
price,
|
Price = price,
|
||||||
total = quantity * price,
|
TotalValue = quantity * price,
|
||||||
now = DateTime.UtcNow,
|
TradedAt = DateTime.UtcNow,
|
||||||
orderId = ibkrOrderId,
|
IbkrOrderId = ibkrOrderId,
|
||||||
notes
|
Status = "Executed",
|
||||||
|
Notes = notes,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.Info(module, $"Trade gespeichert: {action} {quantity}x {symbol} @ {price:F2}");
|
_logger.Info(module, $"Trade gespeichert: {action} {quantity}x {symbol} @ {price:F2}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<dynamic>> GetRecentTradesAsync(int limit = 100)
|
public async Task<IReadOnlyList<CoreTrade>> GetRecentTradesAsync(int limit = 100)
|
||||||
=> await _db.QueryAsync<dynamic>(
|
{
|
||||||
"SELECT * FROM `core_trade_history` ORDER BY traded_at DESC LIMIT @limit",
|
await using var db = await _dbf.CreateDbContextAsync();
|
||||||
new { limit });
|
return await db.TradeHistory
|
||||||
|
.OrderByDescending(t => t.TradedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user