Initial commit: IBKRTrader

.NET WinForms-Anwendung (Core, Modules/CongressTrading, UI).
Enthaelt .gitignore und settings.example.json als Konfigurationsvorlage.
Echte settings.json mit Zugangsdaten ist bewusst ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-26 18:19:47 +02:00
co-authored by Claude Opus 4.8
commit ebeb035e92
47 changed files with 4527 additions and 0 deletions
@@ -0,0 +1,80 @@
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Modules.CongressTrading.Database;
/// <summary>
/// Legt die ct_xxx-Tabellen idempotent an.
/// Namensschema: ct_{tabellenname} gemäß Architekturregeln.
/// </summary>
public class CongressMigrations
{
private readonly DatabaseService _db;
private readonly LoggingService _logger;
public CongressMigrations(DatabaseService db, LoggingService logger)
{
_db = db;
_logger = logger;
}
public async Task RunAsync()
{
_logger.Info("CT", "Starte CongressTrading-Datenbankmigrationen...");
await CreateCongressMemberTableAsync();
await CreateCongressTradeTableAsync();
_logger.Info("CT", "CongressTrading-Migrationen abgeschlossen.");
}
private Task CreateCongressMemberTableAsync() => _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `ct_congressMember` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`bio_id` VARCHAR(20) NOT NULL UNIQUE,
`name` VARCHAR(200),
`party` VARCHAR(50),
`state` VARCHAR(50),
`chamber` VARCHAR(10),
`profile_url` VARCHAR(500),
`first_seen` DATETIME DEFAULT CURRENT_TIMESTAMP,
`last_updated` DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_bio_id` (`bio_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
private async Task CreateCongressTradeTableAsync()
{
// Tabelle anlegen
await _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS `ct_trade` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`trade_id` VARCHAR(20) NOT NULL UNIQUE,
`member_bio_id` VARCHAR(20) NOT NULL,
`issuer_name` VARCHAR(500),
`issuer_id` VARCHAR(20),
`ticker` VARCHAR(20),
`trade_type` VARCHAR(50),
`chamber` VARCHAR(20),
`owner` VARCHAR(50),
`value` DECIMAL(18,2),
`size_range_low` BIGINT,
`size_range_high` BIGINT,
`trade_date` DATE,
`published_date` DATE,
`detail_url` VARCHAR(500),
`details_fetched` TINYINT(1) NOT NULL DEFAULT 1,
`scraped_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_member` (`member_bio_id`),
INDEX `idx_trade_date` (`trade_date`),
INDEX `idx_ticker` (`ticker`),
INDEX `idx_details` (`details_fetched`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
// Idempotente Spalten-Migrationen (für bestehende DBs)
await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `details_fetched` TINYINT(1) NOT NULL DEFAULT 1;");
await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `chamber` VARCHAR(20);");
await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `owner` VARCHAR(50);");
await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD COLUMN IF NOT EXISTS `value` DECIMAL(18,2);");
await _db.ExecuteAsync("ALTER TABLE `ct_trade` ADD INDEX IF NOT EXISTS `idx_details` (`details_fetched`);");
}
}
@@ -0,0 +1,174 @@
using Dapper;
using IBKRTrader.Core.Database;
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.CongressTrading.Models;
namespace IBKRTrader.Modules.CongressTrading.Database;
/// <summary>
/// Datenbankzugriff für das CongressTrading-Modul.
/// Kapselt alle INSERT/SELECT auf ct_congressMember und ct_trade.
/// </summary>
public class CongressRepository
{
private readonly DatabaseService _db;
private readonly LoggingService _logger;
public CongressRepository(DatabaseService db, LoggingService logger)
{
_db = db;
_logger = logger;
}
// ─── CongressMember ───────────────────────────────────────────────────────
public async Task<bool> MemberExistsAsync(string bioId)
{
var count = await _db.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM `ct_congressMember` WHERE bio_id = @bioId",
new { bioId });
return count > 0;
}
public async Task UpsertMemberAsync(CongressMember member)
{
await _db.ExecuteAsync(@"
INSERT INTO `ct_congressMember` (bio_id, name, party, state, chamber, profile_url)
VALUES (@BioId, @Name, @Party, @State, @Chamber, @ProfileUrl)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
party = VALUES(party),
state = VALUES(state),
chamber = VALUES(chamber),
profile_url = VALUES(profile_url),
last_updated = CURRENT_TIMESTAMP", member);
}
public async Task<HashSet<string>> GetAllMemberBioIdsAsync()
{
var ids = await _db.QueryAsync<string>(
"SELECT bio_id FROM `ct_congressMember`");
return ids.ToHashSet();
}
// ─── CongressTrade ────────────────────────────────────────────────────────
public async Task<bool> TradeExistsAsync(string tradeId)
{
var count = await _db.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM `ct_trade` WHERE trade_id = @tradeId",
new { tradeId });
return count > 0;
}
public async Task<HashSet<string>> GetAllTradeIdsAsync()
{
var ids = await _db.QueryAsync<string>(
"SELECT trade_id FROM `ct_trade`");
return ids.ToHashSet();
}
/// <summary>Fügt einen Trade ein. Ignoriert Duplikate (INSERT IGNORE).</summary>
public async Task InsertTradeAsync(CongressTrade trade, bool detailsFetched = true)
{
await _db.ExecuteAsync(@"
INSERT IGNORE INTO `ct_trade`
(trade_id, member_bio_id, issuer_name, issuer_id, ticker,
trade_type, chamber, owner, value,
trade_date, published_date, detail_url, details_fetched)
VALUES
(@TradeId, @MemberBioId, @IssuerName, @IssuerId, @Ticker,
@TradeType, @Chamber, @Owner, @Value,
@TradeDate, @PublishedDate, @DetailUrl, @DetailsFetched)",
new
{
trade.TradeId,
trade.MemberBioId,
trade.IssuerName,
trade.IssuerId,
trade.Ticker,
trade.TradeType,
trade.Chamber,
trade.Owner,
trade.Value,
TradeDate = trade.TradeDate?.ToString("yyyy-MM-dd"),
PublishedDate = trade.PublishedDate?.ToString("yyyy-MM-dd"),
trade.DetailUrl,
DetailsFetched = detailsFetched ? 1 : 0
});
}
/// <summary>Aktualisiert die Detail-Felder eines bereits vorhandenen Trades.</summary>
public async Task UpdateTradeDetailsAsync(CongressTrade trade)
{
await _db.ExecuteAsync(@"
UPDATE `ct_trade`
SET ticker = @Ticker,
trade_type = @TradeType,
trade_date = @TradeDate,
published_date = @PublishedDate,
details_fetched = 1
WHERE trade_id = @TradeId
AND details_fetched = 0",
new
{
trade.TradeId,
trade.Ticker,
trade.TradeType,
TradeDate = trade.TradeDate?.ToString("yyyy-MM-dd"),
PublishedDate = trade.PublishedDate?.ToString("yyyy-MM-dd")
});
}
/// <summary>Gibt Trade-IDs zurück für die noch keine Details geholt wurden.</summary>
public async Task<IEnumerable<string>> GetTradesWithoutDetailsAsync(int limit = 500)
=> await _db.QueryAsync<string>(
"SELECT trade_id FROM `ct_trade` WHERE details_fetched = 0 LIMIT @limit",
new { limit });
public async Task<int> GetTradeCountAsync()
=> await _db.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM `ct_trade`");
public async Task<int> GetMemberCountAsync()
=> await _db.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM `ct_congressMember`");
// ─── core_settings Flags ──────────────────────────────────────────────────
public async Task<string?> GetSettingAsync(string key)
{
return await _db.QueryFirstOrDefaultAsync<string>(
"SELECT `value` FROM `core_settings` WHERE `key` = @key",
new { key });
}
public async Task SetSettingAsync(string key, string value)
{
await _db.ExecuteAsync(@"
INSERT INTO `core_settings` (`key`, `value`)
VALUES (@key, @value)
ON DUPLICATE KEY UPDATE `value` = @value",
new { key, value });
}
public async Task DeleteSettingAsync(string key)
{
await _db.ExecuteAsync(
"DELETE FROM `core_settings` WHERE `key` = @key", new { key });
}
// ─── Reset ────────────────────────────────────────────────────────────────
/// <summary>
/// Löscht alle CT-Fortschritts-Flags und alle CT-Daten aus der DB.
/// Danach startet der History-Import beim nächsten Worker-Run von Seite 1.
/// </summary>
public async Task ResetHistoryImportAsync()
{
await DeleteSettingAsync("ct.history_import_done");
await DeleteSettingAsync("ct.history_import_page");
await DeleteSettingAsync("ct.history_import_total");
await _db.ExecuteAsync("DELETE FROM `ct_trade`");
await _db.ExecuteAsync("DELETE FROM `ct_congressMember`");
_logger.Warn("CT", "History-Import-Reset durchgeführt alle CT-Daten gelöscht.");
}
}