R3 Slice 4: IBKR-Marktdaten auf EF; Dapper vollstaendig entfernt

- 3 IBKR-Entities (IBKRInstrument/IBKRMarketBar/IBKRExternalIdentifier) im CoreDbContext
  (core_ibkr_instruments/_market_data/_external_identifiers); Migration AddIbkr
- IBKRMarketDataRepository von Dapper auf EF (IDbContextFactory<CoreDbContext>);
  Cross-Modul-Query (ct_trade) via Database.SqlQueryRaw
- DatabaseService, IBKRMigrations, Dapper-Package entfernt; Laufzeit-Migrationen komplett weg
  (Schema extern via dotnet ef database update)
- Tests: +4 IBKRMarketDataRepository (EF-InMemory) -> 43/43 gruen; Build + smoke-ui + App-Start ok

R3 abgeschlossen: Persistenz vollstaendig auf EF Core.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 18:29:00 +02:00
parent 6c8e36dc3c
commit 475c0b41cb
19 changed files with 888 additions and 341 deletions
+2 -11
View File
@@ -1,4 +1,3 @@
using IBKRTrader.Core.Database.Migrations;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -131,16 +130,8 @@ public sealed class LauncherForm : Form
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level)) if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
_logger.SetMinLevel(level); _logger.SetMinLevel(level);
// core_-Schema läuft über EF-Migrationen (extern angewendet). Nur ibkr_ (Dapper) noch hier. // Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via
SetStatus("Migrationen..."); // `dotnet ef database update` angewendet keine Laufzeit-Migration mehr.
try
{
await _services.GetRequiredService<IBKRMigrations>().RunAsync();
}
catch (Exception ex)
{
_logger.Error("Core", "IBKR-Datenbankfehler beim Start.", ex);
}
foreach (var module in _modules) foreach (var module in _modules)
{ {
-4
View File
@@ -1,8 +1,6 @@
using IBKRTrader.Core.AI; using IBKRTrader.Core.AI;
using IBKRTrader.Core.Budget; using IBKRTrader.Core.Budget;
using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Database.Migrations;
using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.DependencyInjection;
using IBKRTrader.Core.IBKR; using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
@@ -94,8 +92,6 @@ internal static class Program
services.AddSingleton<LoggingService>(); services.AddSingleton<LoggingService>();
services.AddSingleton<DatabaseService>(); // noch für IBKR-Marktdaten (ibkr_, Dapper)
services.AddSingleton<IBKRMigrations>(); // ibkr_-Tabellen (Dapper); core_ läuft über EF
services.AddSingleton<CoreSettingsService>(); // core_settings via EF services.AddSingleton<CoreSettingsService>(); // core_settings via EF
services.AddSingleton<IBKRGatewayService>(); services.AddSingleton<IBKRGatewayService>();
+3 -1
View File
@@ -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 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<CoreDbContext>`) umgestellt; **5 EF-InMemory-Unit-Tests** (Buchführung real verifiziert). WorkerBase-Log folgt in Slice 4. - [x] **Slice 2:** `PortfolioService`, `BudgetService`, `TradeHistoryService` auf EF (`IDbContextFactory<CoreDbContext>`) umgestellt; **5 EF-InMemory-Unit-Tests** (Buchführung real verifiziert). WorkerBase-Log folgt in Slice 4.
- [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) - [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`) - Hinweis: nur build-verifizierbar (Unit-Tests ohne DB); Schema-Anwendung extern via `dotnet ef database update` (env `IBKRTRADER_MYSQL`)
### R4 Trading-Kern einфügen ### R4 Trading-Kern einфügen
@@ -1,119 +0,0 @@
using Dapper;
using MySqlConnector;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Database;
/// <summary>
/// Zentraler Datenbankzugriff via MySqlConnector + Dapper.
/// Jede Methode öffnet eine eigene Connection (Connection-Pooling via MySqlConnector).
/// </summary>
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<DateOnly>
{
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<bool> 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<IEnumerable<T>> QueryAsync<T>(string sql, object? param = null)
{
await using var conn = CreateConnection();
return await conn.QueryAsync<T>(sql, param);
}
public async Task<T?> QueryFirstOrDefaultAsync<T>(string sql, object? param = null)
{
await using var conn = CreateConnection();
return await conn.QueryFirstOrDefaultAsync<T>(sql, param);
}
public async Task<int> ExecuteAsync(string sql, object? param = null)
{
await using var conn = CreateConnection();
return await conn.ExecuteAsync(sql, param);
}
public async Task<T> ExecuteScalarAsync<T>(string sql, object? param = null)
{
await using var conn = CreateConnection();
#pragma warning disable CS8603
return await conn.ExecuteScalarAsync<T>(sql, param);
#pragma warning restore CS8603
}
// ─── Worker-Log Hilfsmethoden ─────────────────────────────────────────────
/// <summary>Legt einen neuen Worker-Log-Eintrag an und gibt die neue ID zurück.</summary>
public async Task<long> 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<long>(sql,
new { workerName, module, now = DateTime.UtcNow });
}
/// <summary>Schließt einen Worker-Log-Eintrag ab (Success oder Error).</summary>
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
});
}
}
@@ -1,85 +0,0 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Database.Migrations;
/// <summary>
/// Erstellt die core_ibkr_xxx-Tabellen idempotent (IF NOT EXISTS).
/// Wird beim App-Start nach CoreMigrations ausgeführt.
/// </summary>
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;");
}
@@ -1,152 +1,154 @@
using Dapper;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.IBKR; namespace IBKRTrader.Core.IBKR;
/// <summary> /// <summary>
/// Datenzugriffsschicht für die core_ibkr_xxx-Tabellen. /// Datenzugriffsschicht für die core_ibkr_-Tabellen (EF Core).
/// Alle IBKR-Marktdaten-Operationen laufen über diese Klasse.
/// </summary> /// </summary>
public class IBKRMarketDataRepository public class IBKRMarketDataRepository
{ {
private readonly DatabaseService _db; private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger; private readonly LoggingService _logger;
public IBKRMarketDataRepository(DatabaseService db, LoggingService logger) public IBKRMarketDataRepository(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
{ {
_db = db; _dbf = dbf;
_logger = logger; _logger = logger;
} }
// ─── Instruments ───────────────────────────────────────────────────────── // ─── Instruments ─────────────────────────────────────────────────────────
/// <summary> /// <summary>Upsert eines Instruments (per ConID). Gibt die Instrument-ID zurück.</summary>
/// Legt ein neues Instrument an oder aktualisiert ein bestehendes (UPSERT via conid).
/// Gibt die Instrument-ID zurück.
/// </summary>
public async Task<long> UpsertInstrumentAsync(IBKRInstrument instr) public async Task<long> UpsertInstrumentAsync(IBKRInstrument instr)
{ {
const string sql = @" await using var db = await _dbf.CreateDbContextAsync();
INSERT INTO `core_ibkr_instruments` var existing = await db.Instruments.FirstOrDefaultAsync(i => i.IbkrConid == instr.IbkrConid);
(`ibkr_conid`, `symbol`, `sec_type`, `exchange`, `primary_exchange`, if (existing is null)
`currency`, `company_name`, `isin`, `sector`, `industry`, {
`description`, `active`, `last_fetched`) instr.CreatedAt = DateTime.UtcNow;
VALUES instr.UpdatedAt = DateTime.UtcNow;
(@IbkrConid, @Symbol, @SecType, @Exchange, @PrimaryExchange, db.Instruments.Add(instr);
@Currency, @CompanyName, @Isin, @Sector, @Industry, await db.SaveChangesAsync();
@Description, @Active, @LastFetched) return instr.Id;
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 conn = _db.CreateConnection(); existing.Symbol = instr.Symbol;
return await conn.ExecuteScalarAsync<long>(sql, instr); 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;
} }
/// <summary>Gibt alle aktiven Instrumente zurück.</summary> public async Task<IEnumerable<IBKRInstrument>> GetAllActiveInstrumentsAsync()
public Task<IEnumerable<IBKRInstrument>> GetAllActiveInstrumentsAsync() {
=> _db.QueryAsync<IBKRInstrument>( await using var db = await _dbf.CreateDbContextAsync();
"SELECT * FROM `core_ibkr_instruments` WHERE `active` = 1 ORDER BY `symbol`"); return await db.Instruments.Where(i => i.Active).OrderBy(i => i.Symbol).ToListAsync();
}
/// <summary>Findet ein Instrument anhand seiner IBKR ConID.</summary> public async Task<IBKRInstrument?> GetInstrumentByConidAsync(long conid)
public Task<IBKRInstrument?> GetInstrumentByConidAsync(long conid) {
=> _db.QueryFirstOrDefaultAsync<IBKRInstrument>( await using var db = await _dbf.CreateDbContextAsync();
"SELECT * FROM `core_ibkr_instruments` WHERE `ibkr_conid` = @conid", return await db.Instruments.FirstOrDefaultAsync(i => i.IbkrConid == conid);
new { conid }); }
/// <summary>Gibt die Anzahl aktiver Instrumente zurück.</summary> public async Task<int> GetActiveInstrumentCountAsync()
public Task<int> GetActiveInstrumentCountAsync() {
=> _db.ExecuteScalarAsync<int>( await using var db = await _dbf.CreateDbContextAsync();
"SELECT COUNT(*) FROM `core_ibkr_instruments` WHERE `active` = 1"); return await db.Instruments.CountAsync(i => i.Active);
}
// ─── Market Data ───────────────────────────────────────────────────────── // ─── Market Data ─────────────────────────────────────────────────────────
/// <summary> /// <summary>Upsert einer Menge OHLCV-Balken (per PK InstrumentId+BarSize+Timestamp).</summary>
/// Fügt Marktdaten-Balken via UPSERT ein (ON DUPLICATE KEY UPDATE).
/// </summary>
public async Task UpsertMarketDataBatchAsync(IEnumerable<IBKRMarketBar> bars) public async Task UpsertMarketDataBatchAsync(IEnumerable<IBKRMarketBar> bars)
{ {
const string sql = @" await using var db = await _dbf.CreateDbContextAsync();
INSERT INTO `core_ibkr_market_data` foreach (var bar in bars)
(`instrument_id`, `bar_size`, `timestamp`, {
`open`, `high`, `low`, `close`, `volume`, `wap`, `bar_count`) var existing = await db.MarketBars.FindAsync(bar.InstrumentId, bar.BarSize, bar.Timestamp);
VALUES if (existing is null)
(@InstrumentId, @BarSize, @Timestamp, {
@Open, @High, @Low, @Close, @Volume, @Wap, @BarCount) db.MarketBars.Add(bar);
ON DUPLICATE KEY UPDATE }
`open` = VALUES(`open`), else
`high` = VALUES(`high`), {
`low` = VALUES(`low`), existing.Open = bar.Open;
`close` = VALUES(`close`), existing.High = bar.High;
`volume` = VALUES(`volume`), existing.Low = bar.Low;
`wap` = VALUES(`wap`), existing.Close = bar.Close;
`bar_count` = VALUES(`bar_count`)"; existing.Volume = bar.Volume;
existing.Wap = bar.Wap;
await using var conn = _db.CreateConnection(); existing.BarCount = bar.BarCount;
await conn.OpenAsync(); }
await conn.ExecuteAsync(sql, bars); }
await db.SaveChangesAsync();
} }
/// <summary>Gibt den neuesten Timestamp für ein Instrument zurück.</summary> public async Task<DateTime?> GetLatestBarTimestampAsync(long instrumentId, string barSize = "daily")
public Task<DateTime?> GetLatestBarTimestampAsync(long instrumentId, string barSize = "daily") {
=> _db.QueryFirstOrDefaultAsync<DateTime?>( await using var db = await _dbf.CreateDbContextAsync();
@"SELECT MAX(`timestamp`) FROM `core_ibkr_market_data` var bars = db.MarketBars.Where(m => m.InstrumentId == instrumentId && m.BarSize == barSize);
WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize", if (!await bars.AnyAsync()) return null;
new { instrumentId, barSize }); return await bars.MaxAsync(m => m.Timestamp);
}
/// <summary>Gibt die Anzahl Bars für ein Instrument zurück.</summary> public async Task<int> GetBarCountAsync(long instrumentId, string barSize = "daily")
public Task<int> GetBarCountAsync(long instrumentId, string barSize = "daily") {
=> _db.ExecuteScalarAsync<int>( await using var db = await _dbf.CreateDbContextAsync();
@"SELECT COUNT(*) FROM `core_ibkr_market_data` return await db.MarketBars.CountAsync(m => m.InstrumentId == instrumentId && m.BarSize == barSize);
WHERE `instrument_id` = @instrumentId AND `bar_size` = @barSize", }
new { instrumentId, barSize });
// ─── External Identifiers ──────────────────────────────────────────────── // ─── External Identifiers ────────────────────────────────────────────────
/// <summary> /// <summary>Legt ein Ticker-Mapping an (ignoriert Duplikate).</summary>
/// Erstellt oder ignoriert ein External-Identifier-Mapping (IGNORE bei Duplikat). public async Task UpsertExternalIdentifierAsync(long instrumentId, string source, string ticker)
/// </summary> {
public Task UpsertExternalIdentifierAsync(long instrumentId, string source, string ticker) await using var db = await _dbf.CreateDbContextAsync();
=> _db.ExecuteAsync(@" var exists = await db.ExternalIds.AnyAsync(
INSERT IGNORE INTO `core_ibkr_external_identifiers` e => e.InstrumentId == instrumentId && e.Source == source && e.Ticker == ticker);
(`instrument_id`, `source`, `ticker`) if (exists) return;
VALUES (@instrumentId, @source, @ticker)",
new { instrumentId, source, ticker }); db.ExternalIds.Add(new IBKRExternalIdentifier { InstrumentId = instrumentId, Source = source, Ticker = ticker });
await db.SaveChangesAsync();
}
public async Task<IBKRInstrument?> 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();
}
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
public Task<IBKRInstrument?> FindInstrumentByExternalTickerAsync(string source, string ticker) public async Task<IEnumerable<string>> GetUnmappedTickersFromCongressTradesAsync()
=> _db.QueryFirstOrDefaultAsync<IBKRInstrument>(@" {
SELECT i.* FROM `core_ibkr_instruments` i await using var db = await _dbf.CreateDbContextAsync();
INNER JOIN `core_ibkr_external_identifiers` e ON e.`instrument_id` = i.`id` const string sql = @"
WHERE e.`source` = @source AND e.`ticker` = @ticker SELECT DISTINCT t.`Ticker` AS `Value`
LIMIT 1",
new { source, ticker });
/// <summary>
/// Findet alle einzigartigen Ticker aus ct_trade, die noch kein IBKR-Mapping haben.
/// </summary>
public Task<IEnumerable<string>> GetUnmappedTickersFromCongressTradesAsync()
=> _db.QueryAsync<string>(@"
SELECT DISTINCT t.`ticker`
FROM `ct_trade` t FROM `ct_trade` t
WHERE t.`ticker` IS NOT NULL WHERE t.`Ticker` IS NOT NULL AND t.`Ticker` <> ''
AND t.`ticker` != ''
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM `core_ibkr_external_identifiers` e SELECT 1 FROM `core_ibkr_external_identifiers` e
WHERE e.`source` = 'capitoltrades' AND e.`ticker` = t.`ticker` WHERE e.`Source` = 'capitoltrades' AND e.`Ticker` = t.`Ticker`)
) ORDER BY t.`Ticker`";
ORDER BY t.`ticker`"); return await db.Database.SqlQueryRaw<string>(sql).ToListAsync();
}
} }
@@ -11,8 +11,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<!-- EF Core / Pomelo (MariaDB 11.8.6). EF 8 laeuft auf net10. --> <!-- EF Core / Pomelo (MariaDB 11.8.6). EF 8 laeuft auf net10. -->
@@ -1,3 +1,4 @@
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,6 +18,11 @@ public class CoreDbContext : DbContext
public DbSet<CoreWorkerLog> WorkerLog => Set<CoreWorkerLog>(); public DbSet<CoreWorkerLog> WorkerLog => Set<CoreWorkerLog>();
public DbSet<CoreSetting> Settings => Set<CoreSetting>(); public DbSet<CoreSetting> Settings => Set<CoreSetting>();
// IBKR-Marktdaten
public DbSet<IBKRInstrument> Instruments => Set<IBKRInstrument>();
public DbSet<IBKRMarketBar> MarketBars => Set<IBKRMarketBar>();
public DbSet<IBKRExternalIdentifier> ExternalIds => Set<IBKRExternalIdentifier>();
protected override void OnModelCreating(ModelBuilder b) protected override void OnModelCreating(ModelBuilder b)
{ {
b.Entity<CorePosition>(e => b.Entity<CorePosition>(e =>
@@ -71,5 +77,47 @@ public class CoreDbContext : DbContext
e.Property(x => x.Key).HasMaxLength(100); e.Property(x => x.Key).HasMaxLength(100);
e.Property(x => x.Value).HasColumnType("text"); e.Property(x => x.Value).HasColumnType("text");
}); });
b.Entity<IBKRInstrument>(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<IBKRMarketBar>(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<IBKRExternalIdentifier>(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);
});
} }
} }
@@ -0,0 +1,351 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("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<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<decimal>("MaxPerTrade")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("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<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Module", "Symbol");
b.ToTable("core_position", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("core_settings", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IbkrOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Message")
.HasColumnType("longtext");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("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
}
}
}
@@ -0,0 +1,139 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddIbkr : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "core_ibkr_external_identifiers",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
InstrumentId = table.Column<long>(type: "bigint", nullable: false),
Source = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Ticker = table.Column<string>(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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
IbkrConid = table.Column<long>(type: "bigint", nullable: false),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SecType = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Exchange = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PrimaryExchange = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Currency = table.Column<string>(type: "varchar(5)", maxLength: 5, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CompanyName = table.Column<string>(type: "text", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Isin = table.Column<string>(type: "varchar(12)", maxLength: 12, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Sector = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Industry = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "text", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Active = table.Column<bool>(type: "tinyint(1)", nullable: false),
LastFetched = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(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<long>(type: "bigint", nullable: false),
BarSize = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Open = table.Column<decimal>(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false),
High = table.Column<decimal>(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false),
Low = table.Column<decimal>(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false),
Close = table.Column<decimal>(type: "decimal(12,6)", precision: 12, scale: 6, nullable: false),
Volume = table.Column<long>(type: "bigint", nullable: false),
Wap = table.Column<decimal>(type: "decimal(12,6)", precision: 12, scale: 6, nullable: true),
BarCount = table.Column<int>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -22,6 +22,159 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("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 => modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b =>
{ {
b.Property<string>("Module") b.Property<string>("Module")
@@ -1,6 +1,5 @@
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -1,6 +1,5 @@
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.IBKR; using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -1,6 +1,5 @@
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.IBKR; using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore;
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -2,7 +2,6 @@ using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Net; using System.Net;
using System.Text; using System.Text;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
@@ -1,6 +1,5 @@
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
@@ -1,6 +1,5 @@
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
@@ -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;
/// <summary>IBKR-Repo gegen EF-InMemory (Raw-SQL-Cross-Modul-Query wird hier nicht getestet).</summary>
[Trait("cat", "unit")]
public class IBKRMarketDataRepositoryTests
{
private sealed class Factory(DbContextOptions<CoreDbContext> o) : IDbContextFactory<CoreDbContext>
{
public CoreDbContext CreateDbContext() => new(o);
}
private static IBKRMarketDataRepository CreateSut()
{
var opts = new DbContextOptionsBuilder<CoreDbContext>()
.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();
}
}