From 6c8e36dc3ce57192eafb3360b9b11f1acf8f4198 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 28 Jul 2026 11:34:42 +0200 Subject: [PATCH] @ R3 Slice 3: CongressTrading-Modul + Worker-Log + core_settings auf EF Core - Modul: CongressTradingDbContext (ct_congressMember/ct_trade) + Design-Time-Factory; CongressRepository von Dapper auf EF; CongressTrade.DetailsFetched ergaenzt; Dapper entfernt - Core: CoreSettingsService (core_settings via EF); WorkerBase-Log auf EF (core_worker_log); 7 Worker-Ctors DatabaseService -> IDbContextFactory - CoreMigrations + CongressMigrations (Dapper) entfernt; core_-Schema nun rein EF - EF-Migration InitialCongressTrading; AddCorePersistence-Fallback fuer leeren Connection-String (App startet ohne DB); Connection aus appsettings.Local.json (gitignored) - Tests: +4 CongressRepository (EF-InMemory) -> 39/39 gruen; Build + smoke-ui + App-Start ok Co-Authored-By: Claude Opus 4.8 @ --- LauncherForm.cs | 4 +- Program.cs | 6 +- docs/ARCHITECTURE.md | 4 +- .../Database/Migrations/CoreMigrations.cs | 91 --------- .../ServiceCollectionExtensions.cs | 12 +- .../Settings/CoreSettingsService.cs | 48 +++++ .../Workers/BuiltIn/BackupWorker.cs | 4 +- .../BuiltIn/IBKRInstrumentSyncWorker.cs | 4 +- .../Workers/BuiltIn/IBKRPriceHistoryWorker.cs | 4 +- .../Workers/BuiltIn/WebApiService.cs | 4 +- .../Workers/BuiltIn/WebserverService.cs | 4 +- src/IBKRTrader.Core/Workers/WorkerBase.cs | 42 +++- .../CongressTradingModule.cs | 23 +-- .../Database/CongressMigrations.cs | 80 -------- .../Database/CongressRepository.cs | 191 ++++++++---------- .../IBKRTrader.Modules.CongressTrading.csproj | 6 +- .../Models/CongressTrade.cs | 3 +- .../Ef/CongressTradingDbContext.cs | 52 +++++ .../Ef/CongressTradingDbContextFactory.cs | 21 ++ ...8092729_InitialCongressTrading.Designer.cs | 165 +++++++++++++++ .../20260728092729_InitialCongressTrading.cs | 119 +++++++++++ .../CongressTradingDbContextModelSnapshot.cs | 162 +++++++++++++++ .../Workers/CongressHistoryImportWorker.cs | 4 +- .../Workers/CongressScrapeWorker.cs | 4 +- .../Modules/CongressRepositoryTests.cs | 81 ++++++++ .../Modules/CongressTradingModuleTests.cs | 1 - .../Workers/WorkerBaseTests.cs | 16 +- 27 files changed, 832 insertions(+), 323 deletions(-) delete mode 100644 src/IBKRTrader.Core/Database/Migrations/CoreMigrations.cs create mode 100644 src/IBKRTrader.Core/Settings/CoreSettingsService.cs delete mode 100644 src/IBKRTrader.Modules.CongressTrading/Database/CongressMigrations.cs create mode 100644 src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContext.cs create mode 100644 src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContextFactory.cs create mode 100644 src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.Designer.cs create mode 100644 src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.cs create mode 100644 src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/CongressTradingDbContextModelSnapshot.cs create mode 100644 tests/IBKRTrader.Tests/Modules/CongressRepositoryTests.cs diff --git a/LauncherForm.cs b/LauncherForm.cs index f0b85eb..85d71ce 100644 --- a/LauncherForm.cs +++ b/LauncherForm.cs @@ -131,15 +131,15 @@ public sealed class LauncherForm : Form if (Enum.TryParse(levelStr, true, out var level)) _logger.SetMinLevel(level); + // core_-Schema läuft über EF-Migrationen (extern angewendet). Nur ibkr_ (Dapper) noch hier. SetStatus("Migrationen..."); try { - await _services.GetRequiredService().RunAsync(); await _services.GetRequiredService().RunAsync(); } catch (Exception ex) { - _logger.Error("Core", "Core-Datenbankfehler beim Start.", ex); + _logger.Error("Core", "IBKR-Datenbankfehler beim Start.", ex); } foreach (var module in _modules) diff --git a/Program.cs b/Program.cs index 6c1442a..bc230ef 100644 --- a/Program.cs +++ b/Program.cs @@ -94,9 +94,9 @@ internal static class Program services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); // noch für IBKR-Marktdaten (ibkr_, Dapper) + services.AddSingleton(); // ibkr_-Tabellen (Dapper); core_ läuft über EF + services.AddSingleton(); // core_settings via EF services.AddSingleton(); services.AddSingleton(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d663038..cf24138 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,8 +94,8 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin - [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 2:** `PortfolioService`, `BudgetService`, `TradeHistoryService` auf EF (`IDbContextFactory`) 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 4:** Dapper + `DatabaseService` + manuelle Migrationen (CoreMigrations/IBKRMigrations/CongressMigrations) entfernen; IBKR-Marktdaten auf EF +- [x] **Slice 3:** Modul auf EF (`CongressTradingDbContext` ct_ + `CongressRepository`); `WorkerBase`-Log auf EF (core_worker_log); `CoreSettingsService` (core_settings); `CoreMigrations`/`CongressMigrations` (Dapper) entfernt; EF-Migration `InitialCongressTrading`; `appsettings.Local.json` (gitignored) als Connection-Quelle; Fallback für leeren Connection-String. **+4 EF-InMemory-Tests** (CongressRepository) +- [ ] **Slice 4:** IBKR-Marktdaten (`ibkr_`) + `IBKRMigrations` auf EF; dann restliches Dapper + `DatabaseService` entfernen - Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`) ### R4 – Trading-Kern einфügen diff --git a/src/IBKRTrader.Core/Database/Migrations/CoreMigrations.cs b/src/IBKRTrader.Core/Database/Migrations/CoreMigrations.cs deleted file mode 100644 index 40698f1..0000000 --- a/src/IBKRTrader.Core/Database/Migrations/CoreMigrations.cs +++ /dev/null @@ -1,91 +0,0 @@ -using IBKRTrader.Core.Logging; - -namespace IBKRTrader.Core.Database.Migrations; - -/// -/// Erstellt alle core_xxx-Tabellen idempotent (IF NOT EXISTS). -/// Wird einmalig beim App-Start ausgeführt. -/// -public class CoreMigrations -{ - private readonly DatabaseService _db; - private readonly LoggingService _logger; - - public CoreMigrations(DatabaseService db, LoggingService logger) - { - _db = db; - _logger = logger; - } - - public async Task RunAsync() - { - _logger.Info("Core", "Starte Core-Datenbankmigrationen..."); - await CreateCoreSettingsAsync(); - await CreateCoreWorkerLogAsync(); - await CreateCoreTradeHistoryAsync(); - await CreateCoreBudgetAsync(); - await CreateCorePositionAsync(); - _logger.Info("Core", "Core-Migrationen abgeschlossen."); - } - - // ─── Tabellen ───────────────────────────────────────────────────────────── - - private Task CreateCoreSettingsAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_settings` ( - `key` VARCHAR(100) NOT NULL PRIMARY KEY, - `value` TEXT, - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private Task CreateCoreWorkerLogAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_worker_log` ( - `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - `worker_name` VARCHAR(100) NOT NULL, - `module` VARCHAR(50) NOT NULL DEFAULT 'Core', - `started_at` DATETIME, - `finished_at` DATETIME, - `status` ENUM('Running','Success','Error') DEFAULT 'Running', - `message` TEXT, - INDEX `idx_worker` (`worker_name`, `started_at`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private Task CreateCoreTradeHistoryAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_trade_history` ( - `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - `module` VARCHAR(50) NOT NULL, - `symbol` VARCHAR(20) NOT NULL, - `action` ENUM('BUY','SELL') NOT NULL, - `quantity` DECIMAL(18,4), - `price` DECIMAL(18,4), - `total_value` DECIMAL(18,4), - `traded_at` DATETIME, - `ibkr_order_id` VARCHAR(100), - `status` VARCHAR(50), - `notes` TEXT, - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX `idx_symbol` (`symbol`), - INDEX `idx_module` (`module`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private Task CreateCoreBudgetAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_budget` ( - `module` VARCHAR(50) NOT NULL PRIMARY KEY, - `total_budget` DECIMAL(18,2) DEFAULT 0, - `used_budget` DECIMAL(18,2) DEFAULT 0, - `max_per_trade` DECIMAL(18,2) DEFAULT 0, - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private Task CreateCorePositionAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_position` ( - `module` VARCHAR(50) NOT NULL, - `symbol` VARCHAR(20) NOT NULL, - `quantity` INT NOT NULL DEFAULT 0, - `avg_price` DECIMAL(18,4) NOT NULL DEFAULT 0, - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`module`, `symbol`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); -} diff --git a/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs index caaad6a..a1c4503 100644 --- a/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -14,8 +14,18 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddCorePersistence(this IServiceCollection services, DatabaseOptions options) { services.AddDbContextFactory(o => - o.UseMySql(options.MySqlConnectionString, DatabaseServerVersion.Value)); + o.UseMySql(EffectiveConnectionString(options.MySqlConnectionString), DatabaseServerVersion.Value)); return services; } + + /// + /// Liefert den Connection-String oder – bei leerem Wert (z. B. fehlende appsettings.Local.json) – + /// einen unschädlichen Platzhalter, damit der DbContext-Optionsbau nicht wirft. Die App startet so + /// unabhängig von der DB; echte Queries schlagen erst bei tatsächlichem Zugriff fehl. + /// + public static string EffectiveConnectionString(string? connectionString) + => string.IsNullOrWhiteSpace(connectionString) + ? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;" + : connectionString; } diff --git a/src/IBKRTrader.Core/Settings/CoreSettingsService.cs b/src/IBKRTrader.Core/Settings/CoreSettingsService.cs new file mode 100644 index 0000000..6c71183 --- /dev/null +++ b/src/IBKRTrader.Core/Settings/CoreSettingsService.cs @@ -0,0 +1,48 @@ +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Core.Settings; + +/// +/// Key-Value-Zugriff auf core_settings (EF Core). Von Core und Modulen für Fortschritts-/Flag-Werte +/// genutzt (z. B. "ct.history_import_page"). +/// +public class CoreSettingsService +{ + private readonly IDbContextFactory _dbf; + + public CoreSettingsService(IDbContextFactory dbf) => _dbf = dbf; + + public async Task GetAsync(string key) + { + await using var db = await _dbf.CreateDbContextAsync(); + var s = await db.Settings.FindAsync(key); + return s?.Value; + } + + public async Task SetAsync(string key, string value) + { + await using var db = await _dbf.CreateDbContextAsync(); + var s = await db.Settings.FindAsync(key); + if (s is null) + db.Settings.Add(new CoreSetting { Key = key, Value = value, UpdatedAt = DateTime.UtcNow }); + else + { + s.Value = value; + s.UpdatedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(); + } + + public async Task DeleteAsync(string key) + { + await using var db = await _dbf.CreateDbContextAsync(); + var s = await db.Settings.FindAsync(key); + if (s is not null) + { + db.Settings.Remove(s); + await db.SaveChangesAsync(); + } + } +} diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs index 7b9e436..23927a2 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; @@ -21,7 +23,7 @@ public class BackupWorker : WorkerBase protected override TimeSpan? Interval => TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.BackupWorker.IntervalMinutes); - public BackupWorker(LoggingService logger, DatabaseService db, SettingsService settings) + public BackupWorker(LoggingService logger, IDbContextFactory db, SettingsService settings) : base(logger, db) { _settings = settings; diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs index 9362870..909ee41 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Database; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; @@ -22,7 +24,7 @@ public class IBKRInstrumentSyncWorker : WorkerBase private readonly IBKRMarketDataRepository _repo; public IBKRInstrumentSyncWorker( - LoggingService logger, DatabaseService db, + LoggingService logger, IDbContextFactory db, SettingsService settings, IBKRGatewayService gateway, IBKRMarketDataRepository repo) : base(logger, db) diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs index f79f46c..bb07898 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Database; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; @@ -29,7 +31,7 @@ public class IBKRPriceHistoryWorker : WorkerBase private readonly IBKRMarketDataRepository _repo; public IBKRPriceHistoryWorker( - LoggingService logger, DatabaseService db, + LoggingService logger, IDbContextFactory db, SettingsService settings, IBKRGatewayService gateway, IBKRMarketDataRepository repo) : base(logger, db) diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs b/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs index 9e1011c..6d898cd 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using System.Net; using System.Text; using System.Text.Json; @@ -27,7 +29,7 @@ public class WebApiService : WorkerBase protected override TimeSpan? Interval => null; - public WebApiService(LoggingService logger, DatabaseService db, SettingsService settings) + public WebApiService(LoggingService logger, IDbContextFactory db, SettingsService settings) : base(logger, db) { _settings = settings; diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs b/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs index 7595b08..345fed8 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using System.Net; using System.Text; using IBKRTrader.Core.Database; @@ -22,7 +24,7 @@ public class WebserverService : WorkerBase protected override TimeSpan? Interval => null; // Service = permanent - public WebserverService(LoggingService logger, DatabaseService db, SettingsService settings) + public WebserverService(LoggingService logger, IDbContextFactory db, SettingsService settings) : base(logger, db) { _settings = settings; diff --git a/src/IBKRTrader.Core/Workers/WorkerBase.cs b/src/IBKRTrader.Core/Workers/WorkerBase.cs index a8c8fc9..5e5639f 100644 --- a/src/IBKRTrader.Core/Workers/WorkerBase.cs +++ b/src/IBKRTrader.Core/Workers/WorkerBase.cs @@ -1,5 +1,7 @@ -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Core.Workers; @@ -26,8 +28,8 @@ public abstract class WorkerBase : IWorker // ─── Infrastruktur ──────────────────────────────────────────────────────── - protected readonly LoggingService Logger; - protected readonly DatabaseService Db; + protected readonly LoggingService Logger; + private readonly IDbContextFactory _dbf; public WorkerInfo Info { get; } = new(); @@ -35,10 +37,10 @@ public abstract class WorkerBase : IWorker private Task? _runLoop; private readonly SemaphoreSlim _triggerSemaphore = new(0, 1); - protected WorkerBase(LoggingService logger, DatabaseService db) + protected WorkerBase(LoggingService logger, IDbContextFactory dbf) { Logger = logger; - Db = db; + _dbf = dbf; Info.WorkerName = Name; Info.Module = Module; @@ -153,13 +155,33 @@ public abstract class WorkerBase : IWorker // ─── DB-Log-Seam (überschreibbar für Unit-Tests) ────────────────────────── - /// Legt den Worker-Log-Eintrag an. Kapselt den DB-Zugriff (testbar). - protected virtual Task BeginRunLogAsync() - => Db.BeginWorkerLogAsync(Name, Module); + /// Legt den Worker-Log-Eintrag an (core_worker_log via EF). Kapselt den DB-Zugriff (testbar). + protected virtual async Task BeginRunLogAsync() + { + await using var db = await _dbf.CreateDbContextAsync(); + var log = new CoreWorkerLog + { + WorkerName = Name, + Module = Module, + StartedAt = DateTime.UtcNow, + Status = "Running" + }; + db.WorkerLog.Add(log); + await db.SaveChangesAsync(); + return log.Id; + } /// Schließt den Worker-Log-Eintrag ab. Kapselt den DB-Zugriff (testbar). - protected virtual Task EndRunLogAsync(long logId, bool success, string? message = null) - => Db.EndWorkerLogAsync(logId, success, message); + protected virtual async Task EndRunLogAsync(long logId, bool success, string? message = null) + { + await using var db = await _dbf.CreateDbContextAsync(); + var log = await db.WorkerLog.FindAsync(logId); + if (log is null) return; + log.FinishedAt = DateTime.UtcNow; + log.Status = success ? "Success" : "Error"; + log.Message = message; + await db.SaveChangesAsync(); + } // ─── Hilfsmethoden ──────────────────────────────────────────────────────── diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index 5da0d59..fba5eaf 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -1,10 +1,14 @@ +using IBKRTrader.Core.Configuration; +using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading.Database; +using IBKRTrader.Modules.CongressTrading.Persistence.Ef; using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.UI; using IBKRTrader.Modules.CongressTrading.Workers; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -22,12 +26,12 @@ public sealed class CongressTradingModule : IModule public string Name => "CongressTrading"; public string DbPrefix => "ct_"; - // In RegisterUi gesetzt (hat den Provider); für Modul-Migrationen in StartAsync genutzt. - private IServiceProvider? _services; - public void RegisterServices(IServiceCollection services, IConfiguration configuration) { - services.AddSingleton(); + // Modul-Persistenz: eigener DbContext (ct_-Tabellen) in derselben MariaDB. + var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); + services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + services.AddSingleton(); services.AddSingleton(); @@ -41,8 +45,6 @@ public sealed class CongressTradingModule : IModule public void RegisterUi(IModuleUiHost host, IServiceProvider services) { - _services = services; - host.RegisterView(new ModuleView { Id = "congresstrading.main", @@ -56,13 +58,8 @@ public sealed class CongressTradingModule : IModule }); } - public async Task StartAsync(CancellationToken cancellationToken) - { - // Modul-Migrationen laufen nach der Core-Initialisierung. - if (_services is null) return; - var migrations = _services.GetRequiredService(); - await migrations.RunAsync(); - } + // DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration). + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/IBKRTrader.Modules.CongressTrading/Database/CongressMigrations.cs b/src/IBKRTrader.Modules.CongressTrading/Database/CongressMigrations.cs deleted file mode 100644 index 3f0d5e2..0000000 --- a/src/IBKRTrader.Modules.CongressTrading/Database/CongressMigrations.cs +++ /dev/null @@ -1,80 +0,0 @@ -using IBKRTrader.Core.Database; -using IBKRTrader.Core.Logging; - -namespace IBKRTrader.Modules.CongressTrading.Database; - -/// -/// Legt die ct_xxx-Tabellen idempotent an. -/// Namensschema: ct_{tabellenname} gemäß Architekturregeln. -/// -public class CongressMigrations -{ - private readonly DatabaseService _db; - private readonly LoggingService _logger; - - public CongressMigrations(DatabaseService db, LoggingService logger) - { - _db = db; - _logger = logger; - } - - public async Task RunAsync() - { - _logger.Info("CT", "Starte CongressTrading-Datenbankmigrationen..."); - await CreateCongressMemberTableAsync(); - await CreateCongressTradeTableAsync(); - _logger.Info("CT", "CongressTrading-Migrationen abgeschlossen."); - } - - private Task CreateCongressMemberTableAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `ct_congressMember` ( - `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - `bio_id` VARCHAR(20) NOT NULL UNIQUE, - `name` VARCHAR(200), - `party` VARCHAR(50), - `state` VARCHAR(50), - `chamber` VARCHAR(10), - `profile_url` VARCHAR(500), - `first_seen` DATETIME DEFAULT CURRENT_TIMESTAMP, - `last_updated` DATETIME DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_bio_id` (`bio_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private async Task CreateCongressTradeTableAsync() - { - // Tabelle anlegen - await _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `ct_trade` ( - `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - `trade_id` VARCHAR(20) NOT NULL UNIQUE, - `member_bio_id` VARCHAR(20) NOT NULL, - `issuer_name` VARCHAR(500), - `issuer_id` VARCHAR(20), - `ticker` VARCHAR(20), - `trade_type` VARCHAR(50), - `chamber` VARCHAR(20), - `owner` VARCHAR(50), - `value` DECIMAL(18,2), - `size_range_low` BIGINT, - `size_range_high` BIGINT, - `trade_date` DATE, - `published_date` DATE, - `detail_url` VARCHAR(500), - `details_fetched` TINYINT(1) NOT NULL DEFAULT 1, - `scraped_at` DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX `idx_member` (`member_bio_id`), - INDEX `idx_trade_date` (`trade_date`), - INDEX `idx_ticker` (`ticker`), - INDEX `idx_details` (`details_fetched`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - // Idempotente Spalten-Migrationen (für bestehende DBs) - await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `details_fetched` TINYINT(1) NOT NULL DEFAULT 1;"); - await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `chamber` VARCHAR(20);"); - await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `owner` VARCHAR(50);"); - await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `value` DECIMAL(18,2);"); - await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD INDEX IF NOT EXISTS `idx_details` (`details_fetched`);"); - } -} - diff --git a/src/IBKRTrader.Modules.CongressTrading/Database/CongressRepository.cs b/src/IBKRTrader.Modules.CongressTrading/Database/CongressRepository.cs index 37e4e9c..24adcfb 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Database/CongressRepository.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Database/CongressRepository.cs @@ -1,53 +1,65 @@ -using Dapper; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Settings; using IBKRTrader.Modules.CongressTrading.Models; +using IBKRTrader.Modules.CongressTrading.Persistence.Ef; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Modules.CongressTrading.Database; /// -/// Datenbankzugriff für das CongressTrading-Modul. -/// Kapselt alle INSERT/SELECT auf ct_congressMember und ct_trade. +/// EF-gestützter Datenbankzugriff für das CongressTrading-Modul (ct_congressMember, ct_trade). +/// Fortschritts-Flags liegen in core_settings (über ). /// public class CongressRepository { - private readonly DatabaseService _db; - private readonly LoggingService _logger; + private readonly IDbContextFactory _dbf; + private readonly CoreSettingsService _settings; + private readonly LoggingService _logger; - public CongressRepository(DatabaseService db, LoggingService logger) + public CongressRepository( + IDbContextFactory dbf, + CoreSettingsService settings, + LoggingService logger) { - _db = db; - _logger = logger; + _dbf = dbf; + _settings = settings; + _logger = logger; } // ─── CongressMember ─────────────────────────────────────────────────────── public async Task MemberExistsAsync(string bioId) { - var count = await _db.ExecuteScalarAsync( - "SELECT COUNT(*) FROM `ct_congressMember` WHERE bio_id = @bioId", - new { bioId }); - return count > 0; + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Members.AnyAsync(m => m.BioId == bioId); } public async Task UpsertMemberAsync(CongressMember member) { - await _db.ExecuteAsync(@" - INSERT INTO `ct_congressMember` (bio_id, name, party, state, chamber, profile_url) - VALUES (@BioId, @Name, @Party, @State, @Chamber, @ProfileUrl) - ON DUPLICATE KEY UPDATE - name = VALUES(name), - party = VALUES(party), - state = VALUES(state), - chamber = VALUES(chamber), - profile_url = VALUES(profile_url), - last_updated = CURRENT_TIMESTAMP", member); + await using var db = await _dbf.CreateDbContextAsync(); + var existing = await db.Members.FirstOrDefaultAsync(m => m.BioId == member.BioId); + if (existing is null) + { + member.FirstSeen = DateTime.UtcNow; + member.LastUpdated = DateTime.UtcNow; + db.Members.Add(member); + } + else + { + existing.Name = member.Name; + existing.Party = member.Party; + existing.State = member.State; + existing.Chamber = member.Chamber; + existing.ProfileUrl = member.ProfileUrl; + existing.LastUpdated = DateTime.UtcNow; + } + await db.SaveChangesAsync(); } public async Task> GetAllMemberBioIdsAsync() { - var ids = await _db.QueryAsync( - "SELECT bio_id FROM `ct_congressMember`"); + await using var db = await _dbf.CreateDbContextAsync(); + var ids = await db.Members.Select(m => m.BioId).ToListAsync(); return ids.ToHashSet(); } @@ -55,120 +67,85 @@ public class CongressRepository public async Task TradeExistsAsync(string tradeId) { - var count = await _db.ExecuteScalarAsync( - "SELECT COUNT(*) FROM `ct_trade` WHERE trade_id = @tradeId", - new { tradeId }); - return count > 0; + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Trades.AnyAsync(t => t.TradeId == tradeId); } public async Task> GetAllTradeIdsAsync() { - var ids = await _db.QueryAsync( - "SELECT trade_id FROM `ct_trade`"); + await using var db = await _dbf.CreateDbContextAsync(); + var ids = await db.Trades.Select(t => t.TradeId).ToListAsync(); return ids.ToHashSet(); } - /// Fügt einen Trade ein. Ignoriert Duplikate (INSERT IGNORE). + /// Fügt einen Trade ein, sofern die TradeId noch nicht existiert. public async Task InsertTradeAsync(CongressTrade trade, bool detailsFetched = true) { - await _db.ExecuteAsync(@" - INSERT IGNORE INTO `ct_trade` - (trade_id, member_bio_id, issuer_name, issuer_id, ticker, - trade_type, chamber, owner, value, - trade_date, published_date, detail_url, details_fetched) - VALUES - (@TradeId, @MemberBioId, @IssuerName, @IssuerId, @Ticker, - @TradeType, @Chamber, @Owner, @Value, - @TradeDate, @PublishedDate, @DetailUrl, @DetailsFetched)", - new - { - trade.TradeId, - trade.MemberBioId, - trade.IssuerName, - trade.IssuerId, - trade.Ticker, - trade.TradeType, - trade.Chamber, - trade.Owner, - trade.Value, - TradeDate = trade.TradeDate?.ToString("yyyy-MM-dd"), - PublishedDate = trade.PublishedDate?.ToString("yyyy-MM-dd"), - trade.DetailUrl, - DetailsFetched = detailsFetched ? 1 : 0 - }); + await using var db = await _dbf.CreateDbContextAsync(); + if (await db.Trades.AnyAsync(t => t.TradeId == trade.TradeId)) return; + + trade.DetailsFetched = detailsFetched; + trade.ScrapedAt = DateTime.UtcNow; + trade.MemberSnapshot = null; // transient + db.Trades.Add(trade); + await db.SaveChangesAsync(); } - /// Aktualisiert die Detail-Felder eines bereits vorhandenen Trades. + /// Aktualisiert die Detail-Felder eines noch unvollständigen Trades. public async Task UpdateTradeDetailsAsync(CongressTrade trade) { - await _db.ExecuteAsync(@" - UPDATE `ct_trade` - SET ticker = @Ticker, - trade_type = @TradeType, - trade_date = @TradeDate, - published_date = @PublishedDate, - details_fetched = 1 - WHERE trade_id = @TradeId - AND details_fetched = 0", - new - { - trade.TradeId, - trade.Ticker, - trade.TradeType, - TradeDate = trade.TradeDate?.ToString("yyyy-MM-dd"), - PublishedDate = trade.PublishedDate?.ToString("yyyy-MM-dd") - }); + await using var db = await _dbf.CreateDbContextAsync(); + var existing = await db.Trades.FirstOrDefaultAsync(t => t.TradeId == trade.TradeId && !t.DetailsFetched); + if (existing is null) return; + + existing.Ticker = trade.Ticker; + existing.TradeType = trade.TradeType; + existing.TradeDate = trade.TradeDate; + existing.PublishedDate = trade.PublishedDate; + existing.DetailsFetched = true; + await db.SaveChangesAsync(); } - /// Gibt Trade-IDs zurück für die noch keine Details geholt wurden. public async Task> GetTradesWithoutDetailsAsync(int limit = 500) - => await _db.QueryAsync( - "SELECT trade_id FROM `ct_trade` WHERE details_fetched = 0 LIMIT @limit", - new { limit }); + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Trades + .Where(t => !t.DetailsFetched) + .Select(t => t.TradeId) + .Take(limit) + .ToListAsync(); + } public async Task GetTradeCountAsync() - => await _db.ExecuteScalarAsync("SELECT COUNT(*) FROM `ct_trade`"); + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Trades.CountAsync(); + } public async Task GetMemberCountAsync() - => await _db.ExecuteScalarAsync("SELECT COUNT(*) FROM `ct_congressMember`"); - - // ─── core_settings Flags ────────────────────────────────────────────────── - - public async Task GetSettingAsync(string key) { - return await _db.QueryFirstOrDefaultAsync( - "SELECT `value` FROM `core_settings` WHERE `key` = @key", - new { key }); + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Members.CountAsync(); } - public async Task SetSettingAsync(string key, string value) - { - await _db.ExecuteAsync(@" - INSERT INTO `core_settings` (`key`, `value`) - VALUES (@key, @value) - ON DUPLICATE KEY UPDATE `value` = @value", - new { key, value }); - } + // ─── Fortschritts-Flags (core_settings via Core) ────────────────────────── - public async Task DeleteSettingAsync(string key) - { - await _db.ExecuteAsync( - "DELETE FROM `core_settings` WHERE `key` = @key", new { key }); - } + public Task GetSettingAsync(string key) => _settings.GetAsync(key); + public Task SetSettingAsync(string key, string v) => _settings.SetAsync(key, v); + public Task DeleteSettingAsync(string key) => _settings.DeleteAsync(key); // ─── Reset ──────────────────────────────────────────────────────────────── - /// - /// Löscht alle CT-Fortschritts-Flags und alle CT-Daten aus der DB. - /// Danach startet der History-Import beim nächsten Worker-Run von Seite 1. - /// + /// Löscht alle CT-Fortschritts-Flags und alle CT-Daten aus der DB. public async Task ResetHistoryImportAsync() { await DeleteSettingAsync("ct.history_import_done"); await DeleteSettingAsync("ct.history_import_page"); await DeleteSettingAsync("ct.history_import_total"); - await _db.ExecuteAsync("DELETE FROM `ct_trade`"); - await _db.ExecuteAsync("DELETE FROM `ct_congressMember`"); + + await using var db = await _dbf.CreateDbContextAsync(); + await db.Trades.ExecuteDeleteAsync(); + await db.Members.ExecuteDeleteAsync(); _logger.Warn("CT", "History-Import-Reset durchgeführt – alle CT-Daten gelöscht."); } } diff --git a/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj b/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj index cfb2867..a4e1177 100644 --- a/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj +++ b/src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj @@ -10,7 +10,11 @@ - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/IBKRTrader.Modules.CongressTrading/Models/CongressTrade.cs b/src/IBKRTrader.Modules.CongressTrading/Models/CongressTrade.cs index ae0e2cd..e5bf967 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Models/CongressTrade.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Models/CongressTrade.cs @@ -16,8 +16,9 @@ public class CongressTrade public DateOnly? TradeDate { get; set; } public DateOnly? PublishedDate { get; set; } public string DetailUrl { get; set; } = ""; + public bool DetailsFetched { get; set; } = true; public DateTime ScrapedAt { get; set; } = DateTime.UtcNow; - /// Transient – wird direkt in ct_congressMember gespeichert. + /// Transient – wird direkt in ct_congressMember gespeichert (nicht persistiert). public CongressMember? MemberSnapshot { get; set; } } diff --git a/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContext.cs b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContext.cs new file mode 100644 index 0000000..dcde778 --- /dev/null +++ b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContext.cs @@ -0,0 +1,52 @@ +using IBKRTrader.Modules.CongressTrading.Models; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Modules.CongressTrading.Persistence.Ef; + +/// +/// EF-Core-Kontext des CongressTrading-Moduls (ct_-Tabellen in derselben MariaDB wie der Core). +/// +public class CongressTradingDbContext : DbContext +{ + public CongressTradingDbContext(DbContextOptions options) : base(options) { } + + public DbSet Members => Set(); + public DbSet Trades => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("ct_congressMember"); + e.HasKey(x => x.Id); + e.HasIndex(x => x.BioId).IsUnique(); + e.Property(x => x.BioId).HasMaxLength(20); + e.Property(x => x.Name).HasMaxLength(200); + e.Property(x => x.Party).HasMaxLength(50); + e.Property(x => x.State).HasMaxLength(50); + e.Property(x => x.Chamber).HasMaxLength(10); + e.Property(x => x.ProfileUrl).HasMaxLength(500); + }); + + b.Entity(e => + { + e.ToTable("ct_trade"); + e.HasKey(x => x.Id); + e.Ignore(x => x.MemberSnapshot); // transient – nicht persistieren + e.HasIndex(x => x.TradeId).IsUnique(); + e.HasIndex(x => x.MemberBioId); + e.HasIndex(x => x.Ticker); + e.HasIndex(x => x.DetailsFetched); + e.Property(x => x.TradeId).HasMaxLength(20); + e.Property(x => x.MemberBioId).HasMaxLength(20); + e.Property(x => x.IssuerName).HasMaxLength(500); + e.Property(x => x.IssuerId).HasMaxLength(20); + e.Property(x => x.Ticker).HasMaxLength(20); + e.Property(x => x.TradeType).HasMaxLength(50); + e.Property(x => x.Chamber).HasMaxLength(20); + e.Property(x => x.Owner).HasMaxLength(50); + e.Property(x => x.Value).HasPrecision(18, 2); + e.Property(x => x.DetailUrl).HasMaxLength(500); + }); + } +} diff --git a/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContextFactory.cs b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContextFactory.cs new file mode 100644 index 0000000..03f4928 --- /dev/null +++ b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/CongressTradingDbContextFactory.cs @@ -0,0 +1,21 @@ +using IBKRTrader.Core.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace IBKRTrader.Modules.CongressTrading.Persistence.Ef; + +/// Design-Time-Factory für EF-Tooling (dotnet ef). Connection aus env IBKRTRADER_MYSQL. +public class CongressTradingDbContextFactory : IDesignTimeDbContextFactory +{ + public CongressTradingDbContext CreateDbContext(string[] args) + { + var conn = Environment.GetEnvironmentVariable("IBKRTRADER_MYSQL") + ?? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;"; + + var options = new DbContextOptionsBuilder() + .UseMySql(conn, DatabaseServerVersion.Value) + .Options; + + return new CongressTradingDbContext(options); + } +} diff --git a/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.Designer.cs b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.Designer.cs new file mode 100644 index 0000000..c3d2f6d --- /dev/null +++ b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.Designer.cs @@ -0,0 +1,165 @@ +// +using System; +using IBKRTrader.Modules.CongressTrading.Persistence.Ef; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Modules.CongressTrading.Persistence.Ef.Migrations +{ + [DbContext(typeof(CongressTradingDbContext))] + [Migration("20260728092729_InitialCongressTrading")] + partial class InitialCongressTrading + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.Modules.CongressTrading.Models.CongressMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BioId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Chamber") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("FirstSeen") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdated") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Party") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProfileUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("BioId") + .IsUnique(); + + b.ToTable("ct_congressMember", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Modules.CongressTrading.Models.CongressTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Chamber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DetailUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DetailsFetched") + .HasColumnType("tinyint(1)"); + + b.Property("IssuerId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IssuerName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MemberBioId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublishedDate") + .HasColumnType("date"); + + b.Property("ScrapedAt") + .HasColumnType("datetime(6)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TradeDate") + .HasColumnType("date"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TradeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("DetailsFetched"); + + b.HasIndex("MemberBioId"); + + b.HasIndex("Ticker"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("ct_trade", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.cs b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.cs new file mode 100644 index 0000000..bfe0cd9 --- /dev/null +++ b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/20260728092729_InitialCongressTrading.cs @@ -0,0 +1,119 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Modules.CongressTrading.Persistence.Ef.Migrations +{ + /// + public partial class InitialCongressTrading : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ct_congressMember", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + BioId = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Party = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + State = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Chamber = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ProfileUrl = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + FirstSeen = table.Column(type: "datetime(6)", nullable: false), + LastUpdated = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ct_congressMember", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ct_trade", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TradeId = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MemberBioId = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IssuerName = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IssuerId = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Ticker = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + TradeType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Chamber = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Owner = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Value = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true), + TradeDate = table.Column(type: "date", nullable: true), + PublishedDate = table.Column(type: "date", nullable: true), + DetailUrl = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DetailsFetched = table.Column(type: "tinyint(1)", nullable: false), + ScrapedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ct_trade", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ct_congressMember_BioId", + table: "ct_congressMember", + column: "BioId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ct_trade_DetailsFetched", + table: "ct_trade", + column: "DetailsFetched"); + + migrationBuilder.CreateIndex( + name: "IX_ct_trade_MemberBioId", + table: "ct_trade", + column: "MemberBioId"); + + migrationBuilder.CreateIndex( + name: "IX_ct_trade_Ticker", + table: "ct_trade", + column: "Ticker"); + + migrationBuilder.CreateIndex( + name: "IX_ct_trade_TradeId", + table: "ct_trade", + column: "TradeId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ct_congressMember"); + + migrationBuilder.DropTable( + name: "ct_trade"); + } + } +} diff --git a/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/CongressTradingDbContextModelSnapshot.cs b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/CongressTradingDbContextModelSnapshot.cs new file mode 100644 index 0000000..c44ba53 --- /dev/null +++ b/src/IBKRTrader.Modules.CongressTrading/Persistence/Ef/Migrations/CongressTradingDbContextModelSnapshot.cs @@ -0,0 +1,162 @@ +// +using System; +using IBKRTrader.Modules.CongressTrading.Persistence.Ef; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Modules.CongressTrading.Persistence.Ef.Migrations +{ + [DbContext(typeof(CongressTradingDbContext))] + partial class CongressTradingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.Modules.CongressTrading.Models.CongressMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BioId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Chamber") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("FirstSeen") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdated") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Party") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProfileUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("BioId") + .IsUnique(); + + b.ToTable("ct_congressMember", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Modules.CongressTrading.Models.CongressTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Chamber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DetailUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DetailsFetched") + .HasColumnType("tinyint(1)"); + + b.Property("IssuerId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IssuerName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MemberBioId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublishedDate") + .HasColumnType("date"); + + b.Property("ScrapedAt") + .HasColumnType("datetime(6)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TradeDate") + .HasColumnType("date"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TradeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("DetailsFetched"); + + b.HasIndex("MemberBioId"); + + b.HasIndex("Ticker"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("ct_trade", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs index 2757275..77b80cc 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Modules.CongressTrading.Database; @@ -34,7 +36,7 @@ public class CongressHistoryImportWorker : WorkerBase public CongressHistoryImportWorker( LoggingService logger, - DatabaseService db, + IDbContextFactory db, CapitolTradesScraper scraper, CongressRepository repo) : base(logger, db) diff --git a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs index a5f2c95..366286d 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs @@ -1,3 +1,5 @@ +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; @@ -25,7 +27,7 @@ public class CongressScrapeWorker : WorkerBase public CongressScrapeWorker( LoggingService logger, - DatabaseService db, + IDbContextFactory db, CapitolTradesScraper scraper, CongressRepository repo, SettingsService settings) diff --git a/tests/IBKRTrader.Tests/Modules/CongressRepositoryTests.cs b/tests/IBKRTrader.Tests/Modules/CongressRepositoryTests.cs new file mode 100644 index 0000000..65c276c --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/CongressRepositoryTests.cs @@ -0,0 +1,81 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Settings; +using IBKRTrader.Modules.CongressTrading.Database; +using IBKRTrader.Modules.CongressTrading.Models; +using IBKRTrader.Modules.CongressTrading.Persistence.Ef; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Tests.Modules; + +/// EF-Repo des Moduls gegen EF-InMemory (deterministisch, kein externer DB-Zugriff). +[Trait("cat", "unit")] +public class CongressRepositoryTests +{ + private sealed class Factory(DbContextOptions options) : IDbContextFactory where T : DbContext + { + public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!; + } + + private static CongressRepository CreateSut() + { + var ctOpts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var coreOpts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + + var settings = new CoreSettingsService(new Factory(coreOpts)); + return new CongressRepository(new Factory(ctOpts), settings, new LoggingService()); + } + + [Fact] + public async Task UpsertMember_IsIdempotent_ByBioId() + { + var repo = CreateSut(); + + await repo.UpsertMemberAsync(new CongressMember { BioId = "W1", Name = "Alice" }); + await repo.UpsertMemberAsync(new CongressMember { BioId = "W1", Name = "Alice B." }); + + (await repo.MemberExistsAsync("W1")).Should().BeTrue(); + (await repo.GetMemberCountAsync()).Should().Be(1); + (await repo.GetAllMemberBioIdsAsync()).Should().Contain("W1"); + } + + [Fact] + public async Task InsertTrade_IgnoresDuplicateTradeId() + { + var repo = CreateSut(); + + await repo.InsertTradeAsync(new CongressTrade { TradeId = "T1", MemberBioId = "W1", Ticker = "AAPL", TradeType = "buy" }); + await repo.InsertTradeAsync(new CongressTrade { TradeId = "T1", MemberBioId = "W1", Ticker = "AAPL" }); + + (await repo.TradeExistsAsync("T1")).Should().BeTrue(); + (await repo.GetTradeCountAsync()).Should().Be(1); + } + + [Fact] + public async Task DetailsFlow_MarksTradeComplete() + { + var repo = CreateSut(); + await repo.InsertTradeAsync(new CongressTrade { TradeId = "T2" }, detailsFetched: false); + + (await repo.GetTradesWithoutDetailsAsync()).Should().Contain("T2"); + + await repo.UpdateTradeDetailsAsync(new CongressTrade { TradeId = "T2", Ticker = "MSFT", TradeType = "sell" }); + + (await repo.GetTradesWithoutDetailsAsync()).Should().NotContain("T2"); + } + + [Fact] + public async Task Settings_RoundTripThroughCoreSettings() + { + var repo = CreateSut(); + + await repo.SetSettingAsync("ct.history_import_page", "7"); + (await repo.GetSettingAsync("ct.history_import_page")).Should().Be("7"); + + await repo.DeleteSettingAsync("ct.history_import_page"); + (await repo.GetSettingAsync("ct.history_import_page")).Should().BeNull(); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs b/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs index bd6f39d..58db752 100644 --- a/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs +++ b/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs @@ -45,7 +45,6 @@ public class CongressTradingModuleTests var types = services.Select(d => d.ServiceType).ToList(); types.Should().Contain(new[] { - typeof(CongressMigrations), typeof(CongressRepository), typeof(CapitolTradesScraper), typeof(CongressHistoryImportWorker), diff --git a/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs b/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs index 2234ffd..8b5e714 100644 --- a/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs +++ b/tests/IBKRTrader.Tests/Workers/WorkerBaseTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using FluentAssertions; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; -using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Workers; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Tests.Workers; @@ -26,9 +26,17 @@ public class WorkerBaseTests public override string Module => "TEST"; protected override TimeSpan? Interval => _interval; + private sealed class InMemoryFactory(DbContextOptions o) : IDbContextFactory + { + public CoreDbContext CreateDbContext() => new(o); + } + + private static IDbContextFactory Dbf() => + new InMemoryFactory(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + public TestWorker(TimeSpan? interval, Func body) - : base(new LoggingService(), - new DatabaseService(new SettingsService(), new LoggingService())) + : base(new LoggingService(), Dbf()) { _interval = interval; _body = body;