Portierung der beiden fehlenden Grundbausteine aus PolytraderSharp (voller Ausbau). Core S-0 (Datenfundament fuer Analyse/Forensik): - core_decision_journal + core_order_events (+ ReasonCode/Decision/OrderEvent-Enums), IDecisionJournal/IOrderEventLog mit fehlertoleranten EF-Impls (Handel bricht nie). - SignalId-Durchreichung TradeSignal -> ExecutionService -> core_trade_history; ExecutionService schreibt an jeder Verzweigung Journal/Order-Events. - JSONL-Log-Sink (LogJson + Dual-Sink), pure Analytik: RealizedPnlEngine (FIFO), TradeAnalytics, DossierBuilder. Migration AddAnalysisFoundation. Accounting-Modul (acc_): unabhaengiger IBKR-Kontoauszug (Activity Flex Query) hinter Interfaces mit Offline-Null-Stubs -> append-only Ledger + Periodenabrechnung/BWA + FX (USD/EUR) + CSV/PDF (PDFsharp/MigraDoc). Steuerschicht bewusst offen (Platzhalter-Tab). Kein Handel. Migration InitialAccounting. Supervisor-Modul (sup_): read-only OpenRouter-Agent (Function-Calling-Loop) + read-only Tool-Registry (8 Tools) + Profile + Dossier-Browser + Counterfactual-Job (Stub) + Tagesbericht/MCP-Light (opt-in). Migration InitialSupervisor. Verdrahtung: Program.cs (beide Module + Icons), slnx/App/Tests-Referenzen, provision-db.ps1, AppSettings-Sektionen, docs/konzepte, README. Tests: 79 -> 117 gruen (FIFO/KPIs/Dossier/JSONL, Classifier/Engine/FX/Idempotenz, OpenRouter/Registry/Agent/MCP, STA-Konstruktion beider neuen Fenster). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
4.0 KiB
C#
102 lines
4.0 KiB
C#
using FluentAssertions;
|
|
using IBKRTrader.Core.Logging;
|
|
using IBKRTrader.Modules.Accounting.Models;
|
|
using IBKRTrader.Modules.Accounting.Persistence;
|
|
using IBKRTrader.Modules.Accounting.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace IBKRTrader.Tests.Modules.Accounting;
|
|
|
|
/// <summary>Ingest-Kern gegen EF-InMemory: Idempotenz (Doppel-Ingest bucht nicht doppelt) + Balance-Anker.</summary>
|
|
[Trait("cat", "unit")]
|
|
public class AccountingIngestServiceTests
|
|
{
|
|
private sealed class Factory(DbContextOptions<AccountingDbContext> options) : IDbContextFactory<AccountingDbContext>
|
|
{
|
|
public AccountingDbContext CreateDbContext() => new(options);
|
|
}
|
|
|
|
private sealed class FakeStatement : IStatementSource
|
|
{
|
|
public IReadOnlyList<RawExecution> Executions = Array.Empty<RawExecution>();
|
|
public IReadOnlyList<RawCashTransaction> Cash = Array.Empty<RawCashTransaction>();
|
|
public Task<IReadOnlyList<RawExecution>> GetExecutionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Executions);
|
|
public Task<IReadOnlyList<RawCashTransaction>> GetCashTransactionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Cash);
|
|
}
|
|
|
|
private sealed class FakeBalance(decimal? v) : IBalanceAnchorSource
|
|
{
|
|
public Task<decimal?> GetBalanceAsync(string a, CancellationToken ct) => Task.FromResult(v);
|
|
}
|
|
|
|
private static (AccountingIngestService svc, ILedgerRepository ledger) Build(FakeStatement stmt, decimal? anchor = null)
|
|
{
|
|
var opts = new DbContextOptionsBuilder<AccountingDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
var dbf = new Factory(opts);
|
|
var ledger = new EfLedgerRepository(dbf);
|
|
var svc = new AccountingIngestService(
|
|
new NullAccountSource(), ledger, new EfIngestRunRepository(dbf), new EfRawSnapshotRepository(dbf),
|
|
stmt, new FakeBalance(anchor), new LoggingService());
|
|
return (svc, ledger);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DoubleIngest_IsIdempotent()
|
|
{
|
|
var stmt = new FakeStatement
|
|
{
|
|
Executions = new[]
|
|
{
|
|
new RawExecution { AccountId = "U1", TradeId = "T1", Side = "BUY", GrossBase = 1000m, FeeBase = 1m,
|
|
Timestamp = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc) }
|
|
},
|
|
Cash = new[]
|
|
{
|
|
new RawCashTransaction { AccountId = "U1", TransactionId = "C1", Type = "Dividends", AmountBase = 20m,
|
|
Timestamp = new DateTime(2026, 1, 2, 10, 0, 0, DateTimeKind.Utc) }
|
|
}
|
|
};
|
|
var (svc, ledger) = Build(stmt);
|
|
|
|
var run1 = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None);
|
|
var run2 = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None);
|
|
|
|
run1.NewEntries.Should().Be(2);
|
|
run1.DuplicateEntries.Should().Be(0);
|
|
run2.NewEntries.Should().Be(0); // zweiter Lauf bucht nichts neu
|
|
run2.DuplicateEntries.Should().Be(2);
|
|
ledger.Count("U1").Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputesBalanceDelta_AgainstAnchor()
|
|
{
|
|
var stmt = new FakeStatement
|
|
{
|
|
Cash = new[]
|
|
{
|
|
new RawCashTransaction { AccountId = "U1", TransactionId = "D1", Type = "Deposit", AmountBase = 1000m,
|
|
Timestamp = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc) }
|
|
}
|
|
};
|
|
var (svc, _) = Build(stmt, anchor: 1000m);
|
|
|
|
var run = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None);
|
|
|
|
run.LedgerNetBase.Should().Be(1000m);
|
|
run.BalanceAnchorBase.Should().Be(1000m);
|
|
run.BalanceDeltaBase.Should().Be(0m); // vollständig
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoAccounts_IngestAll_DoesNothing()
|
|
{
|
|
var (svc, ledger) = Build(new FakeStatement());
|
|
|
|
await svc.IngestAllAsync(backfill: false, CancellationToken.None);
|
|
|
|
ledger.DistinctAccounts().Should().BeEmpty();
|
|
}
|
|
}
|