registerFunction($moduleName, 'table', static function (string $name): string { $prefix = 'boersencheck_'; $sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name); return $prefix . $sanitized; }); $mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO { $settings = modules()->settings($moduleName); $useSeparate = !empty($settings['use_separate_db']); if ($useSeparate) { $module = modules()->get($moduleName); $fallback = $module['db_defaults'] ?? []; return modules()->modulePdo($moduleName, $fallback); } $base = app()->basePdo(); if ($base instanceof \PDO) { return $base; } throw new ModuleConfigException( $moduleName, 'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.' ); }); $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void { $pdo = module_fn($moduleName, 'pdo'); $table = static fn (string $name): string => module_fn($moduleName, 'table', $name); $driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); $portfolioTable = $table('portfolios'); $instrumentTable = $table('instruments'); $positionTable = $table('positions'); $quoteTable = $table('quotes'); if ($driver === 'pgsql') { $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( id SERIAL PRIMARY KEY, owner_sub VARCHAR(190) NOT NULL, name VARCHAR(190) NOT NULL, base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', notes TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( id SERIAL PRIMARY KEY, isin VARCHAR(32) NULL, wkn VARCHAR(32) NULL, symbol VARCHAR(32) NULL, name VARCHAR(255) NOT NULL, quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', market VARCHAR(120) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( id SERIAL PRIMARY KEY, owner_sub VARCHAR(190) NOT NULL, portfolio_id INTEGER NOT NULL, instrument_id INTEGER NOT NULL, quantity NUMERIC(20,6) NOT NULL, purchase_price NUMERIC(20,8) NOT NULL, purchase_currency VARCHAR(10) NOT NULL, purchase_date DATE NOT NULL, fees NUMERIC(20,8) NULL, notes TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( id SERIAL PRIMARY KEY, instrument_id INTEGER NOT NULL, price NUMERIC(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, quoted_at TIMESTAMP NOT NULL, source VARCHAR(64) NOT NULL DEFAULT 'manual', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); } elseif ($driver === 'mysql') { $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, owner_sub VARCHAR(190) NOT NULL, name VARCHAR(190) NOT NULL, base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', notes TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY {$portfolioTable}_owner_idx (owner_sub) )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, isin VARCHAR(32) NULL, wkn VARCHAR(32) NULL, symbol VARCHAR(32) NULL, name VARCHAR(255) NOT NULL, quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', market VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY {$instrumentTable}_isin_uniq (isin), KEY {$instrumentTable}_symbol_idx (symbol) )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, owner_sub VARCHAR(190) NOT NULL, portfolio_id INTEGER NOT NULL, instrument_id INTEGER NOT NULL, quantity DECIMAL(20,6) NOT NULL, purchase_price DECIMAL(20,8) NOT NULL, purchase_currency VARCHAR(10) NOT NULL, purchase_date DATE NOT NULL, fees DECIMAL(20,8) NULL, notes TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY {$positionTable}_owner_idx (owner_sub), KEY {$positionTable}_portfolio_idx (portfolio_id), KEY {$positionTable}_instrument_idx (instrument_id) )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, instrument_id INTEGER NOT NULL, price DECIMAL(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, quoted_at DATETIME NOT NULL, source VARCHAR(64) NOT NULL DEFAULT 'manual', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at) )"); } else { $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner_sub VARCHAR(190) NOT NULL, name VARCHAR(190) NOT NULL, base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', notes TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, isin VARCHAR(32) NULL, wkn VARCHAR(32) NULL, symbol VARCHAR(32) NULL, name VARCHAR(255) NOT NULL, quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', market VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner_sub VARCHAR(190) NOT NULL, portfolio_id INTEGER NOT NULL, instrument_id INTEGER NOT NULL, quantity DECIMAL(20,6) NOT NULL, purchase_price DECIMAL(20,8) NOT NULL, purchase_currency VARCHAR(10) NOT NULL, purchase_date DATE NOT NULL, fees DECIMAL(20,8) NULL, notes TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, instrument_id INTEGER NOT NULL, price DECIMAL(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, quoted_at DATETIME NOT NULL, source VARCHAR(64) NOT NULL DEFAULT 'manual', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); } }); $mm->registerFunction($moduleName, 'fx_service', static function (): ?object { if (!is_dir(dirname(__DIR__) . '/mining-checker')) { return null; } try { $config = \Modules\MiningChecker\Infrastructure\ModuleConfig::load(dirname(__DIR__) . '/mining-checker'); $repo = new \Modules\MiningChecker\Infrastructure\MiningRepository( \Modules\MiningChecker\Infrastructure\ConnectionFactory::make($config), $config->tablePrefix() ); $fx = $config->fx(); return new \Modules\MiningChecker\Domain\FxService( $repo, (string) ($fx['url'] ?? 'https://currencyapi.net'), (string) ($fx['currencies_url'] ?? ($fx['url'] ?? 'https://currencyapi.net')), (int) ($fx['timeout'] ?? 10), (int) ($fx['cache_ttl'] ?? 21600), false, (string) ($fx['provider'] ?? 'currencyapi'), (string) ($fx['api_key'] ?? '') ); } catch (\Throwable) { return null; } }); $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array { $service = module_fn('boersenchecker', 'fx_service'); if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { return [ 'ok' => false, 'message' => 'Mining-Checker FX-Service ist aktuell nicht verfuegbar.', ]; } try { $result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR'); return [ 'ok' => true, 'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aus dem Mining-Checker aktualisiert.', 'result' => $result, ]; } catch (\Throwable $e) { return [ 'ok' => false, 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), ]; } }); $mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote', static function (string $symbol): array { $settings = modules()->settings('boersenchecker'); $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); $timeout = (int) ($settings['alpha_vantage_timeout_sec'] ?? 12); $timeout = $timeout > 0 ? $timeout : 12; $symbol = strtoupper(trim($symbol)); if ($symbol === '') { return [ 'ok' => false, 'message' => 'Kein API-Symbol hinterlegt.', ]; } if ($apiKey === '') { return [ 'ok' => false, 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', ]; } $url = 'https://www.alphavantage.co/query?' . http_build_query([ 'function' => 'GLOBAL_QUOTE', 'symbol' => $symbol, 'apikey' => $apiKey, ]); $responseBody = null; if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch !== false) { curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min(5, $timeout), CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $responseBody = curl_exec($ch); $curlError = curl_error($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if (!is_string($responseBody) || $responseBody === '') { return [ 'ok' => false, 'message' => 'Alpha Vantage Anfrage fehlgeschlagen.' . ($curlError !== '' ? ' ' . $curlError : '') . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), ]; } } } if (!is_string($responseBody) || $responseBody === '') { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'header' => "Accept: application/json\r\n", ], ]); $responseBody = @file_get_contents($url, false, $context); if (!is_string($responseBody) || $responseBody === '') { return [ 'ok' => false, 'message' => 'Alpha Vantage Anfrage lieferte keine Daten.', ]; } } $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { return [ 'ok' => false, 'message' => 'Alpha Vantage Antwort ist kein gueltiges JSON.', ]; } if (!empty($decoded['Note'])) { return [ 'ok' => false, 'message' => 'Alpha Vantage Limit-Hinweis: ' . trim((string) $decoded['Note']), ]; } if (!empty($decoded['Information'])) { return [ 'ok' => false, 'message' => trim((string) $decoded['Information']), ]; } if (!empty($decoded['Error Message'])) { return [ 'ok' => false, 'message' => trim((string) $decoded['Error Message']), ]; } $quote = is_array($decoded['Global Quote'] ?? null) ? $decoded['Global Quote'] : []; $price = $quote['05. price'] ?? null; if (!is_numeric($price)) { return [ 'ok' => false, 'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.', ]; } return [ 'ok' => true, 'symbol' => (string) ($quote['01. symbol'] ?? $symbol), 'price' => (float) $price, 'latest_trading_day' => (string) ($quote['07. latest trading day'] ?? ''), 'previous_close' => is_numeric($quote['08. previous close'] ?? null) ? (float) $quote['08. previous close'] : null, 'change' => is_numeric($quote['09. change'] ?? null) ? (float) $quote['09. change'] : null, 'change_percent' => (string) ($quote['10. change percent'] ?? ''), 'fetched_at' => date('Y-m-d H:i:s'), 'source' => 'alpha_vantage:GLOBAL_QUOTE', 'raw' => $quote, ]; }); $mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array { $settings = modules()->settings('boersenchecker'); $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); $timeout = (int) ($settings['alpha_vantage_timeout_sec'] ?? 12); $timeout = $timeout > 0 ? $timeout : 12; $keywords = trim($keywords); if ($keywords === '') { return [ 'ok' => false, 'message' => 'Bitte Suchbegriff angeben.', 'results' => [], ]; } if ($apiKey === '') { return [ 'ok' => false, 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', 'results' => [], ]; } $url = 'https://www.alphavantage.co/query?' . http_build_query([ 'function' => 'SYMBOL_SEARCH', 'keywords' => $keywords, 'apikey' => $apiKey, ]); $responseBody = null; if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch !== false) { curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min(5, $timeout), CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $responseBody = curl_exec($ch); $curlError = curl_error($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if (!is_string($responseBody) || $responseBody === '') { return [ 'ok' => false, 'message' => 'Alpha Vantage Suche fehlgeschlagen.' . ($curlError !== '' ? ' ' . $curlError : '') . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), 'results' => [], ]; } } } if (!is_string($responseBody) || $responseBody === '') { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'header' => "Accept: application/json\r\n", ], ]); $responseBody = @file_get_contents($url, false, $context); if (!is_string($responseBody) || $responseBody === '') { return [ 'ok' => false, 'message' => 'Alpha Vantage Suche lieferte keine Daten.', 'results' => [], ]; } } $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { return [ 'ok' => false, 'message' => 'Alpha Vantage Suchantwort ist kein gueltiges JSON.', 'results' => [], ]; } if (!empty($decoded['Note'])) { return [ 'ok' => false, 'message' => 'Alpha Vantage Limit-Hinweis: ' . trim((string) $decoded['Note']), 'results' => [], ]; } if (!empty($decoded['Information'])) { return [ 'ok' => false, 'message' => trim((string) $decoded['Information']), 'results' => [], ]; } if (!empty($decoded['Error Message'])) { return [ 'ok' => false, 'message' => trim((string) $decoded['Error Message']), 'results' => [], ]; } $matches = is_array($decoded['bestMatches'] ?? null) ? $decoded['bestMatches'] : []; $results = []; foreach ($matches as $match) { if (!is_array($match)) { continue; } $results[] = [ 'symbol' => trim((string) ($match['1. symbol'] ?? '')), 'name' => trim((string) ($match['2. name'] ?? '')), 'type' => trim((string) ($match['3. type'] ?? '')), 'region' => trim((string) ($match['4. region'] ?? '')), 'market_open' => trim((string) ($match['5. marketOpen'] ?? '')), 'market_close' => trim((string) ($match['6. marketClose'] ?? '')), 'timezone' => trim((string) ($match['7. timezone'] ?? '')), 'currency' => trim((string) ($match['8. currency'] ?? '')), 'match_score' => trim((string) ($match['9. matchScore'] ?? '')), ]; } return [ 'ok' => true, 'message' => count($results) . ' Treffer gefunden.', 'results' => $results, ]; });