diff --git a/LauncherForm.cs b/LauncherForm.cs index 85d71ce..a587ac2 100644 --- a/LauncherForm.cs +++ b/LauncherForm.cs @@ -1,4 +1,3 @@ -using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Settings; @@ -131,16 +130,8 @@ 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(); - } - catch (Exception ex) - { - _logger.Error("Core", "IBKR-Datenbankfehler beim Start.", ex); - } + // Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via + // `dotnet ef database update` angewendet – keine Laufzeit-Migration mehr. foreach (var module in _modules) { diff --git a/Program.cs b/Program.cs index bc230ef..2e57466 100644 --- a/Program.cs +++ b/Program.cs @@ -1,8 +1,6 @@ using IBKRTrader.Core.AI; using IBKRTrader.Core.Budget; using IBKRTrader.Core.Configuration; -using IBKRTrader.Core.Database; -using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; @@ -94,8 +92,6 @@ internal static class Program 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(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cf24138..008c37e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,9 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin - [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. - [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 +- [x] **Slice 4:** IBKR-Marktdaten (`core_ibkr_*`) auf EF (3 Entities im `CoreDbContext`, Cross-Modul-Query via Raw-SQL); Migration `AddIbkr`; **Dapper + `DatabaseService` + `IBKRMigrations` vollständig entfernt** – Persistenz ist jetzt **komplett EF Core**. +4 EF-InMemory-Tests. + +**R3 abgeschlossen** ✅ – gesamte Persistenz auf EF Core (Migrationen: `InitialCore`, `AddIbkr`, `InitialCongressTrading`), extern via `dotnet ef database update` anzuwenden. - 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/DatabaseService.cs b/src/IBKRTrader.Core/Database/DatabaseService.cs deleted file mode 100644 index f1d9d59..0000000 --- a/src/IBKRTrader.Core/Database/DatabaseService.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Dapper; -using MySqlConnector; -using IBKRTrader.Core.Logging; -using IBKRTrader.Core.Settings; - -namespace IBKRTrader.Core.Database; - -/// -/// Zentraler Datenbankzugriff via MySqlConnector + Dapper. -/// Jede Methode öffnet eine eigene Connection (Connection-Pooling via MySqlConnector). -/// -public class DatabaseService -{ - private readonly SettingsService _settings; - private readonly LoggingService _logger; - - public DatabaseService(SettingsService settings, LoggingService logger) - { - _settings = settings; - _logger = logger; - - // Dapper support for DateOnly - SqlMapper.AddTypeHandler(new DateOnlyTypeHandler()); - } - - private class DateOnlyTypeHandler : SqlMapper.TypeHandler - { - public override void SetValue(System.Data.IDbDataParameter parameter, DateOnly value) - { - parameter.Value = value.ToString("yyyy-MM-dd"); - } - - public override DateOnly Parse(object value) - { - if (value is DateTime dt) return DateOnly.FromDateTime(dt); - if (value is string s && DateOnly.TryParse(s, out var d)) return d; - return DateOnly.FromDateTime(Convert.ToDateTime(value)); - } - } - - - // ─── Connection ─────────────────────────────────────────────────────────── - - public MySqlConnection CreateConnection() - => new(_settings.Settings.Database.BuildConnectionString()); - - public async Task TestConnectionAsync() - { - try - { - await using var conn = CreateConnection(); - await conn.OpenAsync(); - _logger.Info("Core", "Datenbankverbindung erfolgreich hergestellt."); - return true; - } - catch (Exception ex) - { - _logger.Error("Core", "Datenbankverbindung fehlgeschlagen.", ex); - return false; - } - } - - // ─── Abfragen (Dapper) ──────────────────────────────────────────────────── - - public async Task> QueryAsync(string sql, object? param = null) - { - await using var conn = CreateConnection(); - return await conn.QueryAsync(sql, param); - } - - public async Task QueryFirstOrDefaultAsync(string sql, object? param = null) - { - await using var conn = CreateConnection(); - return await conn.QueryFirstOrDefaultAsync(sql, param); - } - - public async Task ExecuteAsync(string sql, object? param = null) - { - await using var conn = CreateConnection(); - return await conn.ExecuteAsync(sql, param); - } - - public async Task ExecuteScalarAsync(string sql, object? param = null) - { - await using var conn = CreateConnection(); -#pragma warning disable CS8603 - return await conn.ExecuteScalarAsync(sql, param); -#pragma warning restore CS8603 - } - - // ─── Worker-Log Hilfsmethoden ───────────────────────────────────────────── - - /// Legt einen neuen Worker-Log-Eintrag an und gibt die neue ID zurück. - public async Task BeginWorkerLogAsync(string workerName, string module) - { - const string sql = - @"INSERT INTO `core_worker_log` (worker_name, module, started_at, status) - VALUES (@workerName, @module, @now, 'Running'); - SELECT LAST_INSERT_ID();"; - return await ExecuteScalarAsync(sql, - new { workerName, module, now = DateTime.UtcNow }); - } - - /// Schließt einen Worker-Log-Eintrag ab (Success oder Error). - public async Task EndWorkerLogAsync(long logId, bool success, string? message = null) - { - const string sql = - @"UPDATE `core_worker_log` - SET finished_at = @now, status = @status, message = @msg - WHERE id = @id"; - await ExecuteAsync(sql, new - { - id = logId, - now = DateTime.UtcNow, - status = success ? "Success" : "Error", - msg = message - }); - } -} diff --git a/src/IBKRTrader.Core/Database/Migrations/IBKRMigrations.cs b/src/IBKRTrader.Core/Database/Migrations/IBKRMigrations.cs deleted file mode 100644 index 7a24eda..0000000 --- a/src/IBKRTrader.Core/Database/Migrations/IBKRMigrations.cs +++ /dev/null @@ -1,85 +0,0 @@ -using IBKRTrader.Core.Logging; - -namespace IBKRTrader.Core.Database.Migrations; - -/// -/// Erstellt die core_ibkr_xxx-Tabellen idempotent (IF NOT EXISTS). -/// Wird beim App-Start nach CoreMigrations ausgeführt. -/// -public class IBKRMigrations -{ - private readonly DatabaseService _db; - private readonly LoggingService _logger; - - public IBKRMigrations(DatabaseService db, LoggingService logger) - { - _db = db; - _logger = logger; - } - - public async Task RunAsync() - { - _logger.Info("Core", "Starte IBKR-Datenbankmigrationen..."); - await CreateInstrumentsTableAsync(); - await CreateMarketDataTableAsync(); - await CreateExternalIdentifiersTableAsync(); - _logger.Info("Core", "IBKR-Migrationen abgeschlossen."); - } - - // ─── Tabellen ───────────────────────────────────────────────────────────── - - private Task CreateInstrumentsTableAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_ibkr_instruments` ( - `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - `ibkr_conid` BIGINT NOT NULL UNIQUE, - `symbol` VARCHAR(20) NOT NULL, - `sec_type` VARCHAR(10) NOT NULL DEFAULT 'STK', - `exchange` VARCHAR(20) NOT NULL DEFAULT 'SMART', - `primary_exchange` VARCHAR(20), - `currency` VARCHAR(5) NOT NULL DEFAULT 'USD', - `company_name` TEXT, - `isin` VARCHAR(12), - `sector` VARCHAR(100), - `industry` VARCHAR(100), - `description` TEXT, - `active` TINYINT(1) NOT NULL DEFAULT 1, - `last_fetched` DATETIME, - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_symbol` (`symbol`), - INDEX `idx_conid` (`ibkr_conid`), - INDEX `idx_active` (`active`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private Task CreateMarketDataTableAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_ibkr_market_data` ( - `instrument_id` BIGINT NOT NULL, - `bar_size` VARCHAR(10) NOT NULL DEFAULT 'daily', - `timestamp` DATETIME NOT NULL, - `open` DECIMAL(12,6), - `high` DECIMAL(12,6), - `low` DECIMAL(12,6), - `close` DECIMAL(12,6), - `volume` BIGINT, - `wap` DECIMAL(12,6), - `bar_count` INT, - PRIMARY KEY (`instrument_id`, `bar_size`, `timestamp`), - INDEX `idx_timestamp` (`timestamp`), - CONSTRAINT `fk_md_instrument` FOREIGN KEY (`instrument_id`) - REFERENCES `core_ibkr_instruments` (`id`) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); - - private Task CreateExternalIdentifiersTableAsync() => _db.ExecuteAsync(@" - CREATE TABLE IF NOT EXISTS `core_ibkr_external_identifiers` ( - `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - `instrument_id` BIGINT NOT NULL, - `source` VARCHAR(50) NOT NULL, - `ticker` VARCHAR(50) NOT NULL, - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY `uq_mapping` (`instrument_id`, `source`, `ticker`), - INDEX `idx_source_ticker` (`source`, `ticker`), - CONSTRAINT `fk_ext_instrument` FOREIGN KEY (`instrument_id`) - REFERENCES `core_ibkr_instruments` (`id`) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); -} diff --git a/src/IBKRTrader.Core/IBKR/IBKRMarketDataRepository.cs b/src/IBKRTrader.Core/IBKR/IBKRMarketDataRepository.cs index 3c07883..0dac041 100644 --- a/src/IBKRTrader.Core/IBKR/IBKRMarketDataRepository.cs +++ b/src/IBKRTrader.Core/IBKR/IBKRMarketDataRepository.cs @@ -1,152 +1,154 @@ -using Dapper; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Core.IBKR; /// -/// Datenzugriffsschicht für die core_ibkr_xxx-Tabellen. -/// Alle IBKR-Marktdaten-Operationen laufen über diese Klasse. +/// Datenzugriffsschicht für die core_ibkr_-Tabellen (EF Core). /// public class IBKRMarketDataRepository { - private readonly DatabaseService _db; - private readonly LoggingService _logger; + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; - public IBKRMarketDataRepository(DatabaseService db, LoggingService logger) + public IBKRMarketDataRepository(IDbContextFactory dbf, LoggingService logger) { - _db = db; + _dbf = dbf; _logger = logger; } // ─── Instruments ───────────────────────────────────────────────────────── - /// - /// Legt ein neues Instrument an oder aktualisiert ein bestehendes (UPSERT via conid). - /// Gibt die Instrument-ID zurück. - /// + /// Upsert eines Instruments (per ConID). Gibt die Instrument-ID zurück. public async Task UpsertInstrumentAsync(IBKRInstrument instr) { - const string sql = @" - INSERT INTO `core_ibkr_instruments` - (`ibkr_conid`, `symbol`, `sec_type`, `exchange`, `primary_exchange`, - `currency`, `company_name`, `isin`, `sector`, `industry`, - `description`, `active`, `last_fetched`) - VALUES - (@IbkrConid, @Symbol, @SecType, @Exchange, @PrimaryExchange, - @Currency, @CompanyName, @Isin, @Sector, @Industry, - @Description, @Active, @LastFetched) - ON DUPLICATE KEY UPDATE - `symbol` = VALUES(`symbol`), - `sec_type` = VALUES(`sec_type`), - `exchange` = VALUES(`exchange`), - `primary_exchange` = VALUES(`primary_exchange`), - `currency` = VALUES(`currency`), - `company_name` = VALUES(`company_name`), - `isin` = VALUES(`isin`), - `sector` = VALUES(`sector`), - `industry` = VALUES(`industry`), - `description` = VALUES(`description`), - `last_fetched` = VALUES(`last_fetched`); - SELECT `id` FROM `core_ibkr_instruments` WHERE `ibkr_conid` = @IbkrConid;"; + await using var db = await _dbf.CreateDbContextAsync(); + var existing = await db.Instruments.FirstOrDefaultAsync(i => i.IbkrConid == instr.IbkrConid); + if (existing is null) + { + instr.CreatedAt = DateTime.UtcNow; + instr.UpdatedAt = DateTime.UtcNow; + db.Instruments.Add(instr); + await db.SaveChangesAsync(); + return instr.Id; + } - await using var conn = _db.CreateConnection(); - return await conn.ExecuteScalarAsync(sql, instr); + existing.Symbol = instr.Symbol; + existing.SecType = instr.SecType; + existing.Exchange = instr.Exchange; + existing.PrimaryExchange = instr.PrimaryExchange; + existing.Currency = instr.Currency; + existing.CompanyName = instr.CompanyName; + existing.Isin = instr.Isin; + existing.Sector = instr.Sector; + existing.Industry = instr.Industry; + existing.Description = instr.Description; + existing.LastFetched = instr.LastFetched; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return existing.Id; } - /// Gibt alle aktiven Instrumente zurück. - public Task> GetAllActiveInstrumentsAsync() - => _db.QueryAsync( - "SELECT * FROM `core_ibkr_instruments` WHERE `active` = 1 ORDER BY `symbol`"); + public async Task> GetAllActiveInstrumentsAsync() + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Instruments.Where(i => i.Active).OrderBy(i => i.Symbol).ToListAsync(); + } - /// Findet ein Instrument anhand seiner IBKR ConID. - public Task GetInstrumentByConidAsync(long conid) - => _db.QueryFirstOrDefaultAsync( - "SELECT * FROM `core_ibkr_instruments` WHERE `ibkr_conid` = @conid", - new { conid }); + public async Task GetInstrumentByConidAsync(long conid) + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Instruments.FirstOrDefaultAsync(i => i.IbkrConid == conid); + } - /// Gibt die Anzahl aktiver Instrumente zurück. - public Task GetActiveInstrumentCountAsync() - => _db.ExecuteScalarAsync( - "SELECT COUNT(*) FROM `core_ibkr_instruments` WHERE `active` = 1"); + public async Task GetActiveInstrumentCountAsync() + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.Instruments.CountAsync(i => i.Active); + } // ─── Market Data ───────────────────────────────────────────────────────── - /// - /// Fügt Marktdaten-Balken via UPSERT ein (ON DUPLICATE KEY UPDATE). - /// + /// Upsert einer Menge OHLCV-Balken (per PK InstrumentId+BarSize+Timestamp). public async Task UpsertMarketDataBatchAsync(IEnumerable bars) { - const string sql = @" - INSERT INTO `core_ibkr_market_data` - (`instrument_id`, `bar_size`, `timestamp`, - `open`, `high`, `low`, `close`, `volume`, `wap`, `bar_count`) - VALUES - (@InstrumentId, @BarSize, @Timestamp, - @Open, @High, @Low, @Close, @Volume, @Wap, @BarCount) - ON DUPLICATE KEY UPDATE - `open` = VALUES(`open`), - `high` = VALUES(`high`), - `low` = VALUES(`low`), - `close` = VALUES(`close`), - `volume` = VALUES(`volume`), - `wap` = VALUES(`wap`), - `bar_count` = VALUES(`bar_count`)"; - - await using var conn = _db.CreateConnection(); - await conn.OpenAsync(); - await conn.ExecuteAsync(sql, bars); + await using var db = await _dbf.CreateDbContextAsync(); + foreach (var bar in bars) + { + var existing = await db.MarketBars.FindAsync(bar.InstrumentId, bar.BarSize, bar.Timestamp); + if (existing is null) + { + db.MarketBars.Add(bar); + } + else + { + existing.Open = bar.Open; + existing.High = bar.High; + existing.Low = bar.Low; + existing.Close = bar.Close; + existing.Volume = bar.Volume; + existing.Wap = bar.Wap; + existing.BarCount = bar.BarCount; + } + } + await db.SaveChangesAsync(); } - /// Gibt den neuesten Timestamp für ein Instrument zurück. - public Task GetLatestBarTimestampAsync(long instrumentId, string barSize = "daily") - => _db.QueryFirstOrDefaultAsync( - @"SELECT MAX(`timestamp`) FROM `core_ibkr_market_data` - WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize", - new { instrumentId, barSize }); + public async Task GetLatestBarTimestampAsync(long instrumentId, string barSize = "daily") + { + await using var db = await _dbf.CreateDbContextAsync(); + var bars = db.MarketBars.Where(m => m.InstrumentId == instrumentId && m.BarSize == barSize); + if (!await bars.AnyAsync()) return null; + return await bars.MaxAsync(m => m.Timestamp); + } - /// Gibt die Anzahl Bars für ein Instrument zurück. - public Task GetBarCountAsync(long instrumentId, string barSize = "daily") - => _db.ExecuteScalarAsync( - @"SELECT COUNT(*) FROM `core_ibkr_market_data` - WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize", - new { instrumentId, barSize }); + public async Task GetBarCountAsync(long instrumentId, string barSize = "daily") + { + await using var db = await _dbf.CreateDbContextAsync(); + return await db.MarketBars.CountAsync(m => m.InstrumentId == instrumentId && m.BarSize == barSize); + } // ─── External Identifiers ──────────────────────────────────────────────── - /// - /// Erstellt oder ignoriert ein External-Identifier-Mapping (IGNORE bei Duplikat). - /// - public Task UpsertExternalIdentifierAsync(long instrumentId, string source, string ticker) - => _db.ExecuteAsync(@" - INSERT IGNORE INTO `core_ibkr_external_identifiers` - (`instrument_id`, `source`, `ticker`) - VALUES (@instrumentId, @source, @ticker)", - new { instrumentId, source, ticker }); + /// Legt ein Ticker-Mapping an (ignoriert Duplikate). + public async Task UpsertExternalIdentifierAsync(long instrumentId, string source, string ticker) + { + await using var db = await _dbf.CreateDbContextAsync(); + var exists = await db.ExternalIds.AnyAsync( + e => e.InstrumentId == instrumentId && e.Source == source && e.Ticker == ticker); + if (exists) return; + + db.ExternalIds.Add(new IBKRExternalIdentifier { InstrumentId = instrumentId, Source = source, Ticker = ticker }); + await db.SaveChangesAsync(); + } + + public async Task FindInstrumentByExternalTickerAsync(string source, string ticker) + { + await using var db = await _dbf.CreateDbContextAsync(); + var query = + from e in db.ExternalIds + join i in db.Instruments on e.InstrumentId equals i.Id + where e.Source == source && e.Ticker == ticker + select i; + return await query.FirstOrDefaultAsync(); + } /// - /// Findet ein Instrument anhand eines externen Tickers (z.B. aus ct_trade). + /// Findet alle Ticker aus ct_trade (Modul-Tabelle) ohne IBKR-Mapping. Cross-Tabellen-Query via + /// Raw-SQL, damit der Core den Modul-DbContext nicht referenzieren muss. /// - public Task FindInstrumentByExternalTickerAsync(string source, string ticker) - => _db.QueryFirstOrDefaultAsync(@" - SELECT i.* FROM `core_ibkr_instruments` i - INNER JOIN `core_ibkr_external_identifiers` e ON e.`instrument_id` = i.`id` - WHERE e.`source` = @source AND e.`ticker` = @ticker - LIMIT 1", - new { source, ticker }); - - /// - /// Findet alle einzigartigen Ticker aus ct_trade, die noch kein IBKR-Mapping haben. - /// - public Task> GetUnmappedTickersFromCongressTradesAsync() - => _db.QueryAsync(@" - SELECT DISTINCT t.`ticker` + public async Task> GetUnmappedTickersFromCongressTradesAsync() + { + await using var db = await _dbf.CreateDbContextAsync(); + const string sql = @" + SELECT DISTINCT t.`Ticker` AS `Value` FROM `ct_trade` t - WHERE t.`ticker` IS NOT NULL - AND t.`ticker` != '' + WHERE t.`Ticker` IS NOT NULL AND t.`Ticker` <> '' AND NOT EXISTS ( SELECT 1 FROM `core_ibkr_external_identifiers` e - WHERE e.`source` = 'capitoltrades' AND e.`ticker` = t.`ticker` - ) - ORDER BY t.`ticker`"); + WHERE e.`Source` = 'capitoltrades' AND e.`Ticker` = t.`Ticker`) + ORDER BY t.`Ticker`"; + return await db.Database.SqlQueryRaw(sql).ToListAsync(); + } } diff --git a/src/IBKRTrader.Core/IBKRTrader.Core.csproj b/src/IBKRTrader.Core/IBKRTrader.Core.csproj index 70152d8..bbb0ed6 100644 --- a/src/IBKRTrader.Core/IBKRTrader.Core.csproj +++ b/src/IBKRTrader.Core/IBKRTrader.Core.csproj @@ -11,8 +11,6 @@ - - diff --git a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs index 2d9ff82..3f613a2 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Persistence.Entities; using Microsoft.EntityFrameworkCore; @@ -17,6 +18,11 @@ public class CoreDbContext : DbContext public DbSet WorkerLog => Set(); public DbSet Settings => Set(); + // IBKR-Marktdaten + public DbSet Instruments => Set(); + public DbSet MarketBars => Set(); + public DbSet ExternalIds => Set(); + protected override void OnModelCreating(ModelBuilder b) { b.Entity(e => @@ -71,5 +77,47 @@ public class CoreDbContext : DbContext e.Property(x => x.Key).HasMaxLength(100); e.Property(x => x.Value).HasColumnType("text"); }); + + b.Entity(e => + { + e.ToTable("core_ibkr_instruments"); + e.HasKey(x => x.Id); + e.HasIndex(x => x.IbkrConid).IsUnique(); + e.HasIndex(x => x.Symbol); + e.HasIndex(x => x.Active); + e.Property(x => x.Symbol).HasMaxLength(20); + e.Property(x => x.SecType).HasMaxLength(10); + e.Property(x => x.Exchange).HasMaxLength(20); + e.Property(x => x.PrimaryExchange).HasMaxLength(20); + e.Property(x => x.Currency).HasMaxLength(5); + e.Property(x => x.CompanyName).HasColumnType("text"); + e.Property(x => x.Isin).HasMaxLength(12); + e.Property(x => x.Sector).HasMaxLength(100); + e.Property(x => x.Industry).HasMaxLength(100); + e.Property(x => x.Description).HasColumnType("text"); + }); + + b.Entity(e => + { + e.ToTable("core_ibkr_market_data"); + e.HasKey(x => new { x.InstrumentId, x.BarSize, x.Timestamp }); + e.HasIndex(x => x.Timestamp); + e.Property(x => x.BarSize).HasMaxLength(10); + e.Property(x => x.Open).HasPrecision(12, 6); + e.Property(x => x.High).HasPrecision(12, 6); + e.Property(x => x.Low).HasPrecision(12, 6); + e.Property(x => x.Close).HasPrecision(12, 6); + e.Property(x => x.Wap).HasPrecision(12, 6); + }); + + b.Entity(e => + { + e.ToTable("core_ibkr_external_identifiers"); + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.InstrumentId, x.Source, x.Ticker }).IsUnique(); + e.HasIndex(x => new { x.Source, x.Ticker }); + e.Property(x => x.Source).HasMaxLength(50); + e.Property(x => x.Ticker).HasMaxLength(50); + }); } } diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260728162301_AddIbkr.Designer.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260728162301_AddIbkr.Designer.cs new file mode 100644 index 0000000..8823499 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260728162301_AddIbkr.Designer.cs @@ -0,0 +1,351 @@ +// +using System; +using IBKRTrader.Core.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.Core.Persistence.Ef.Migrations +{ + [DbContext(typeof(CoreDbContext))] + [Migration("20260728162301_AddIbkr")] + partial class AddIbkr + { + /// + 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.Core.IBKR.IBKRExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Ticker"); + + b.HasIndex("InstrumentId", "Source", "Ticker") + .IsUnique(); + + b.ToTable("core_ibkr_external_identifiers", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("CompanyName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Exchange") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IbkrConid") + .HasColumnType("bigint"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Isin") + .HasMaxLength(12) + .HasColumnType("varchar(12)"); + + b.Property("LastFetched") + .HasColumnType("datetime(6)"); + + b.Property("PrimaryExchange") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SecType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Sector") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Active"); + + b.HasIndex("IbkrConid") + .IsUnique(); + + b.HasIndex("Symbol"); + + b.ToTable("core_ibkr_instruments", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b => + { + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("BarSize") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("BarCount") + .HasColumnType("int"); + + b.Property("Close") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("High") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Low") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Open") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.Property("Wap") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.HasKey("InstrumentId", "BarSize", "Timestamp"); + + b.HasIndex("Timestamp"); + + b.ToTable("core_ibkr_market_data", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxPerTrade") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UsedBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Module"); + + b.ToTable("core_budget", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Symbol") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("AvgPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Module", "Symbol"); + + b.ToTable("core_position", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("core_settings", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IbkrOrderId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TotalValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TradedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Module"); + + b.HasIndex("Symbol"); + + b.ToTable("core_trade_history", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WorkerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("WorkerName", "StartedAt"); + + b.ToTable("core_worker_log", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260728162301_AddIbkr.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260728162301_AddIbkr.cs new file mode 100644 index 0000000..4635e29 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260728162301_AddIbkr.cs @@ -0,0 +1,139 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class AddIbkr : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "core_ibkr_external_identifiers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + InstrumentId = table.Column(type: "bigint", nullable: false), + Source = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Ticker = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_core_ibkr_external_identifiers", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "core_ibkr_instruments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + IbkrConid = table.Column(type: "bigint", nullable: false), + Symbol = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SecType = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Exchange = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + PrimaryExchange = table.Column(type: "varchar(20)", maxLength: 20, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Currency = table.Column(type: "varchar(5)", maxLength: 5, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CompanyName = table.Column(type: "text", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Isin = table.Column(type: "varchar(12)", maxLength: 12, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Sector = table.Column(type: "varchar(100)", maxLength: 100, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Industry = table.Column(type: "varchar(100)", maxLength: 100, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "text", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Active = table.Column(type: "tinyint(1)", nullable: false), + LastFetched = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_core_ibkr_instruments", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "core_ibkr_market_data", + columns: table => new + { + InstrumentId = table.Column(type: "bigint", nullable: false), + BarSize = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + Open = table.Column(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false), + High = table.Column(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false), + Low = table.Column(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false), + Close = table.Column(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false), + Volume = table.Column(type: "bigint", nullable: false), + Wap = table.Column(type: "decimal(12,6)", precision: 12, scale: 6, nullable: true), + BarCount = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_core_ibkr_market_data", x => new { x.InstrumentId, x.BarSize, x.Timestamp }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_core_ibkr_external_identifiers_InstrumentId_Source_Ticker", + table: "core_ibkr_external_identifiers", + columns: new[] { "InstrumentId", "Source", "Ticker" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_core_ibkr_external_identifiers_Source_Ticker", + table: "core_ibkr_external_identifiers", + columns: new[] { "Source", "Ticker" }); + + migrationBuilder.CreateIndex( + name: "IX_core_ibkr_instruments_Active", + table: "core_ibkr_instruments", + column: "Active"); + + migrationBuilder.CreateIndex( + name: "IX_core_ibkr_instruments_IbkrConid", + table: "core_ibkr_instruments", + column: "IbkrConid", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_core_ibkr_instruments_Symbol", + table: "core_ibkr_instruments", + column: "Symbol"); + + migrationBuilder.CreateIndex( + name: "IX_core_ibkr_market_data_Timestamp", + table: "core_ibkr_market_data", + column: "Timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "core_ibkr_external_identifiers"); + + migrationBuilder.DropTable( + name: "core_ibkr_instruments"); + + migrationBuilder.DropTable( + name: "core_ibkr_market_data"); + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index 28746b1..a8def56 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -22,6 +22,159 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Ticker"); + + b.HasIndex("InstrumentId", "Source", "Ticker") + .IsUnique(); + + b.ToTable("core_ibkr_external_identifiers", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("CompanyName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Exchange") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IbkrConid") + .HasColumnType("bigint"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Isin") + .HasMaxLength(12) + .HasColumnType("varchar(12)"); + + b.Property("LastFetched") + .HasColumnType("datetime(6)"); + + b.Property("PrimaryExchange") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SecType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Sector") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Active"); + + b.HasIndex("IbkrConid") + .IsUnique(); + + b.HasIndex("Symbol"); + + b.ToTable("core_ibkr_instruments", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b => + { + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("BarSize") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("BarCount") + .HasColumnType("int"); + + b.Property("Close") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("High") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Low") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Open") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.Property("Wap") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.HasKey("InstrumentId", "BarSize", "Timestamp"); + + b.HasIndex("Timestamp"); + + b.ToTable("core_ibkr_market_data", (string)null); + }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b => { b.Property("Module") diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs index 23927a2..85668ab 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs @@ -1,6 +1,5 @@ using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs index 909ee41..4b97d0b 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRInstrumentSyncWorker.cs @@ -1,6 +1,5 @@ using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; -using IBKRTrader.Core.Database; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs index bb07898..31b4da1 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/IBKRPriceHistoryWorker.cs @@ -1,6 +1,5 @@ using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; -using IBKRTrader.Core.Database; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs b/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs index 6d898cd..afb1095 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs @@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore; using System.Net; using System.Text; using System.Text.Json; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs b/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs index 345fed8..25678d0 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs @@ -2,7 +2,6 @@ using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; using System.Net; using System.Text; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; diff --git a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs index 77b80cc..b92d9a5 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressHistoryImportWorker.cs @@ -1,6 +1,5 @@ using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Scraper; diff --git a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs index 366286d..6d5fa02 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs @@ -1,6 +1,5 @@ using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; -using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; using IBKRTrader.Modules.CongressTrading.Database; diff --git a/tests/IBKRTrader.Tests/IBKR/IBKRMarketDataRepositoryTests.cs b/tests/IBKRTrader.Tests/IBKR/IBKRMarketDataRepositoryTests.cs new file mode 100644 index 0000000..04ca800 --- /dev/null +++ b/tests/IBKRTrader.Tests/IBKR/IBKRMarketDataRepositoryTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using IBKRTrader.Core.IBKR; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Tests.IBKR; + +/// IBKR-Repo gegen EF-InMemory (Raw-SQL-Cross-Modul-Query wird hier nicht getestet). +[Trait("cat", "unit")] +public class IBKRMarketDataRepositoryTests +{ + private sealed class Factory(DbContextOptions o) : IDbContextFactory + { + public CoreDbContext CreateDbContext() => new(o); + } + + private static IBKRMarketDataRepository CreateSut() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + return new IBKRMarketDataRepository(new Factory(opts), new LoggingService()); + } + + [Fact] + public async Task UpsertInstrument_IsIdempotent_ByConid_AndReturnsId() + { + var repo = CreateSut(); + + var id1 = await repo.UpsertInstrumentAsync(new IBKRInstrument { IbkrConid = 111, Symbol = "AAPL" }); + var id2 = await repo.UpsertInstrumentAsync(new IBKRInstrument { IbkrConid = 111, Symbol = "AAPL2" }); + + id2.Should().Be(id1); + (await repo.GetActiveInstrumentCountAsync()).Should().Be(1); + (await repo.GetInstrumentByConidAsync(111))!.Symbol.Should().Be("AAPL2"); + } + + [Fact] + public async Task ExternalIdentifier_Dedup_AndLookup() + { + var repo = CreateSut(); + var id = await repo.UpsertInstrumentAsync(new IBKRInstrument { IbkrConid = 222, Symbol = "MSFT" }); + + await repo.UpsertExternalIdentifierAsync(id, "capitoltrades", "MSFT"); + await repo.UpsertExternalIdentifierAsync(id, "capitoltrades", "MSFT"); // Duplikat + + var found = await repo.FindInstrumentByExternalTickerAsync("capitoltrades", "MSFT"); + found.Should().NotBeNull(); + found!.IbkrConid.Should().Be(222); + } + + [Fact] + public async Task MarketData_Upsert_Count_AndLatest() + { + var repo = CreateSut(); + + await repo.UpsertMarketDataBatchAsync(new[] + { + new IBKRMarketBar { InstrumentId = 1, BarSize = "daily", Timestamp = new DateTime(2026, 1, 1), Close = 10m }, + new IBKRMarketBar { InstrumentId = 1, BarSize = "daily", Timestamp = new DateTime(2026, 1, 2), Close = 11m }, + }); + // Upsert desselben Keys aktualisiert (kein zweiter Eintrag). + await repo.UpsertMarketDataBatchAsync(new[] + { + new IBKRMarketBar { InstrumentId = 1, BarSize = "daily", Timestamp = new DateTime(2026, 1, 2), Close = 12m }, + }); + + (await repo.GetBarCountAsync(1)).Should().Be(2); + (await repo.GetLatestBarTimestampAsync(1)).Should().Be(new DateTime(2026, 1, 2)); + } + + [Fact] + public async Task LatestBarTimestamp_IsNull_WhenNoData() + { + var repo = CreateSut(); + (await repo.GetLatestBarTimestampAsync(999)).Should().BeNull(); + } +}