R8: Accounting- + Supervisor-Modul + Core-Datenfundament (S-0)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cbbedb2e0e
commit
2a312ca035
@@ -0,0 +1,96 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Modules.Accounting.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace IBKRTrader.Modules.Accounting.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-Kontext des Accounting-Moduls (gleiche MariaDB, Tabellen mit Präfix acc_). Append-only Ledger mit
|
||||
/// Autoincrement-PKs und Unique-Index auf dem Idempotenz-Schlüssel (kein Doppel-Buchen).
|
||||
/// </summary>
|
||||
public class AccountingDbContext : DbContext
|
||||
{
|
||||
public AccountingDbContext(DbContextOptions<AccountingDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<LedgerEntry> Ledger => Set<LedgerEntry>();
|
||||
public DbSet<IngestRun> IngestRuns => Set<IngestRun>();
|
||||
public DbSet<RawSnapshot> RawSnapshots => Set<RawSnapshot>();
|
||||
public DbSet<FxRate> FxRates => Set<FxRate>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
b.Entity<LedgerEntry>(e =>
|
||||
{
|
||||
e.ToTable("acc_ledger");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Id).ValueGeneratedOnAdd();
|
||||
e.Property(x => x.AccountId).HasMaxLength(30);
|
||||
e.Property(x => x.EventType).HasConversion<string>().HasMaxLength(20);
|
||||
e.Property(x => x.Symbol).HasMaxLength(30);
|
||||
e.Property(x => x.AssetClass).HasMaxLength(10);
|
||||
e.Property(x => x.Currency).HasMaxLength(5);
|
||||
e.Property(x => x.Side).HasMaxLength(10);
|
||||
e.Property(x => x.Source).HasMaxLength(40);
|
||||
e.Property(x => x.TransactionId).HasMaxLength(60);
|
||||
e.Property(x => x.IdempotencyKey).HasMaxLength(120);
|
||||
e.Property(x => x.Quantity).HasPrecision(28, 8);
|
||||
e.Property(x => x.PriceNative).HasPrecision(18, 6);
|
||||
e.Property(x => x.GrossBase).HasPrecision(28, 8);
|
||||
e.Property(x => x.FeeBase).HasPrecision(28, 8);
|
||||
e.Property(x => x.NetBase).HasPrecision(28, 8);
|
||||
e.HasIndex(x => x.IdempotencyKey).IsUnique(); // Idempotenz: kein Doppel-Buchen
|
||||
e.HasIndex(x => new { x.AccountId, x.Timestamp });
|
||||
e.HasIndex(x => x.EventType);
|
||||
});
|
||||
|
||||
b.Entity<IngestRun>(e =>
|
||||
{
|
||||
e.ToTable("acc_ingest_runs");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Id).ValueGeneratedOnAdd();
|
||||
e.Property(x => x.AccountId).HasMaxLength(30);
|
||||
e.Property(x => x.Message).HasMaxLength(1000);
|
||||
e.Property(x => x.BalanceAnchorBase).HasPrecision(28, 8);
|
||||
e.Property(x => x.LedgerNetBase).HasPrecision(28, 8);
|
||||
e.Property(x => x.BalanceDeltaBase).HasPrecision(28, 8);
|
||||
e.HasIndex(x => new { x.AccountId, x.StartedAt });
|
||||
});
|
||||
|
||||
b.Entity<RawSnapshot>(e =>
|
||||
{
|
||||
e.ToTable("acc_raw");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Id).ValueGeneratedOnAdd();
|
||||
e.Property(x => x.AccountId).HasMaxLength(30);
|
||||
e.Property(x => x.SourceKind).HasMaxLength(20);
|
||||
e.Property(x => x.Json).HasColumnType("longtext");
|
||||
e.HasIndex(x => x.IngestRunId);
|
||||
});
|
||||
|
||||
b.Entity<FxRate>(e =>
|
||||
{
|
||||
e.ToTable("acc_fx_rates");
|
||||
e.HasKey(x => x.Date);
|
||||
e.Property(x => x.Date).HasColumnType("date");
|
||||
e.Property(x => x.UsdToEur).HasPrecision(18, 8);
|
||||
e.Property(x => x.Source).HasMaxLength(40);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Design-Time-Factory für EF-Tooling (dotnet ef). Connection aus env IBKRTRADER_MYSQL.</summary>
|
||||
public class AccountingDbContextFactory : IDesignTimeDbContextFactory<AccountingDbContext>
|
||||
{
|
||||
public AccountingDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var conn = Environment.GetEnvironmentVariable("IBKRTRADER_MYSQL")
|
||||
?? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;";
|
||||
|
||||
var options = new DbContextOptionsBuilder<AccountingDbContext>()
|
||||
.UseMySql(conn, DatabaseServerVersion.Value)
|
||||
.Options;
|
||||
|
||||
return new AccountingDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using IBKRTrader.Modules.Accounting.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IBKRTrader.Modules.Accounting.Persistence;
|
||||
|
||||
/// <summary>Append-only Ledger-Zugriff mit idempotentem Upsert (Doppel-Buchen ausgeschlossen).</summary>
|
||||
public interface ILedgerRepository
|
||||
{
|
||||
/// <summary>Fügt den Satz ein, falls sein IdempotencyKey neu ist. true = neu gebucht, false = Duplikat.</summary>
|
||||
bool Upsert(LedgerEntry entry);
|
||||
DateTime? LatestTimestamp(string accountId);
|
||||
decimal SumNet(string accountId);
|
||||
int Count(string accountId);
|
||||
List<string> DistinctAccounts();
|
||||
List<LedgerEntry> Query(string? accountId, DateTime? from, DateTime? to, int limit);
|
||||
/// <summary>ALLE Sätze des Scopes bis <paramref name="to"/> (für die Abrechnung inkl. Anfangssaldo).</summary>
|
||||
List<LedgerEntry> GetUpTo(string? accountId, DateTime to);
|
||||
}
|
||||
|
||||
public interface IIngestRunRepository
|
||||
{
|
||||
void Insert(IngestRun run); // setzt Id
|
||||
void Update(IngestRun run);
|
||||
List<IngestRun> GetRecent(string? accountId, int limit);
|
||||
}
|
||||
|
||||
public interface IRawSnapshotRepository
|
||||
{
|
||||
void Insert(RawSnapshot snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Amtliche FX-Tageskurse (USD→EUR), versioniert. Upsert je Datum.</summary>
|
||||
public interface IFxRateRepository
|
||||
{
|
||||
void Upsert(FxRate rate);
|
||||
List<FxRate> GetAll();
|
||||
}
|
||||
|
||||
// ---------------- EF-Implementierungen ----------------
|
||||
|
||||
public sealed class EfLedgerRepository : ILedgerRepository
|
||||
{
|
||||
private readonly IDbContextFactory<AccountingDbContext> _dbf;
|
||||
public EfLedgerRepository(IDbContextFactory<AccountingDbContext> dbf) => _dbf = dbf;
|
||||
|
||||
public bool Upsert(LedgerEntry entry)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
bool exists = db.Ledger.AsNoTracking().Any(x => x.IdempotencyKey == entry.IdempotencyKey);
|
||||
if (exists) return false;
|
||||
db.Ledger.Add(entry);
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
public DateTime? LatestTimestamp(string accountId)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
return db.Ledger.AsNoTracking()
|
||||
.Where(x => x.AccountId == accountId)
|
||||
.OrderByDescending(x => x.Timestamp)
|
||||
.Select(x => (DateTime?)x.Timestamp)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public decimal SumNet(string accountId)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
return db.Ledger.AsNoTracking().Where(x => x.AccountId == accountId).Sum(x => (decimal?)x.NetBase) ?? 0m;
|
||||
}
|
||||
|
||||
public int Count(string accountId)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
return db.Ledger.AsNoTracking().Count(x => x.AccountId == accountId);
|
||||
}
|
||||
|
||||
public List<string> DistinctAccounts()
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
return db.Ledger.AsNoTracking().Select(x => x.AccountId).Distinct().OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
public List<LedgerEntry> Query(string? accountId, DateTime? from, DateTime? to, int limit)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
var q = db.Ledger.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrEmpty(accountId)) q = q.Where(x => x.AccountId == accountId);
|
||||
if (from.HasValue) q = q.Where(x => x.Timestamp >= from.Value);
|
||||
if (to.HasValue) q = q.Where(x => x.Timestamp <= to.Value);
|
||||
return q.OrderByDescending(x => x.Timestamp).Take(limit).ToList();
|
||||
}
|
||||
|
||||
public List<LedgerEntry> GetUpTo(string? accountId, DateTime to)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
var q = db.Ledger.AsNoTracking().Where(x => x.Timestamp <= to);
|
||||
if (!string.IsNullOrEmpty(accountId)) q = q.Where(x => x.AccountId == accountId);
|
||||
return q.OrderBy(x => x.Timestamp).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EfIngestRunRepository : IIngestRunRepository
|
||||
{
|
||||
private readonly IDbContextFactory<AccountingDbContext> _dbf;
|
||||
public EfIngestRunRepository(IDbContextFactory<AccountingDbContext> dbf) => _dbf = dbf;
|
||||
|
||||
public void Insert(IngestRun run)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
db.IngestRuns.Add(run);
|
||||
db.SaveChanges(); // füllt run.Id (Autoincrement)
|
||||
}
|
||||
|
||||
public void Update(IngestRun run)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
db.IngestRuns.Update(run);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
public List<IngestRun> GetRecent(string? accountId, int limit)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
var q = db.IngestRuns.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrEmpty(accountId)) q = q.Where(x => x.AccountId == accountId);
|
||||
return q.OrderByDescending(x => x.StartedAt).Take(limit).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EfRawSnapshotRepository : IRawSnapshotRepository
|
||||
{
|
||||
private readonly IDbContextFactory<AccountingDbContext> _dbf;
|
||||
public EfRawSnapshotRepository(IDbContextFactory<AccountingDbContext> dbf) => _dbf = dbf;
|
||||
|
||||
public void Insert(RawSnapshot snapshot)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
db.RawSnapshots.Add(snapshot);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EfFxRateRepository : IFxRateRepository
|
||||
{
|
||||
private readonly IDbContextFactory<AccountingDbContext> _dbf;
|
||||
public EfFxRateRepository(IDbContextFactory<AccountingDbContext> dbf) => _dbf = dbf;
|
||||
|
||||
public void Upsert(FxRate rate)
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
var existing = db.FxRates.Find(rate.Date.Date);
|
||||
if (existing == null) db.FxRates.Add(new FxRate { Date = rate.Date.Date, UsdToEur = rate.UsdToEur, Source = rate.Source });
|
||||
else { existing.UsdToEur = rate.UsdToEur; existing.Source = rate.Source; }
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
public List<FxRate> GetAll()
|
||||
{
|
||||
using var db = _dbf.CreateDbContext();
|
||||
return db.FxRates.AsNoTracking().OrderBy(x => x.Date).ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user