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_request', static function ( string $functionName, array $params = [] ): array { $settings = modules()->settings('boersenchecker'); $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); $timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12); $timeout = $timeout > 0 ? $timeout : 12; if ($apiKey === '') { module_debug_push('boersenchecker', [ 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ 'function' => $functionName, 'params' => $params, ], 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', ]); return [ 'ok' => false, 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', ]; } $url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([ 'function' => $functionName, 'apikey' => $apiKey, ], $params), '', '&', PHP_QUERY_RFC3986); $responseBody = null; $httpCode = 0; $curlError = ''; 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 === '') { $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 === '') { module_debug_push('boersenchecker', [ 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ 'function' => $functionName, 'url' => $url, 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, 'curl_error' => $curlError, 'body' => null, ], 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.', ]); return [ 'ok' => false, 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.' . ($curlError !== '' ? ' ' . $curlError : '') . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), ]; } $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { module_debug_push('boersenchecker', [ 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ 'function' => $functionName, 'url' => $url, 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, 'body_preview' => substr($responseBody, 0, 4000), ], 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', ]); return [ 'ok' => false, 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', 'raw_body' => $responseBody, ]; } foreach (['Error Message', 'Information', 'Note'] as $errorKey) { if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') { module_debug_push('boersenchecker', [ 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ 'function' => $functionName, 'url' => $url, 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, 'body' => $decoded, ], 'message' => trim((string) $decoded[$errorKey]), ]); return [ 'ok' => false, 'message' => trim((string) $decoded[$errorKey]), 'raw' => $decoded, ]; } } module_debug_push('boersenchecker', [ 'label' => 'Alpha Vantage Request', 'type' => 'api:response', 'request' => [ 'function' => $functionName, 'url' => $url, 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, 'body' => $decoded, ], ]); return [ 'ok' => true, 'data' => $decoded, ]; }); $mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone { return new \DateTimeZone('Europe/Berlin'); }); $mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string { if (is_numeric($value)) { return gmdate('Y-m-d H:i:s', (int) $value); } $raw = trim((string) $value); if ($raw === '') { return gmdate('Y-m-d H:i:s'); } try { $date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); } catch (\Throwable) { $timestamp = strtotime($raw); return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s'); } }); $mm->registerFunction($moduleName, 'format_datetime_for_display', static function ( ?string $value, ?string $source = null, string $format = 'Y-m-d H:i:s' ): string { $raw = trim((string) $value); if ($raw === '') { return ''; } $displayTimezone = new \DateTimeZone('Europe/Berlin'); $source = trim((string) $source); if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) { $date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC')); if (!$date instanceof \DateTimeImmutable) { try { $date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); } catch (\Throwable) { return $raw; } } return $date->setTimezone($displayTimezone)->format($format); } if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) { try { return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format); } catch (\Throwable) { return $raw; } } return str_replace('T', ' ', $raw); }); $mm->registerFunction($moduleName, 'local_now_input_value', static function (): string { return (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('Y-m-d\TH:i'); }); $mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array { $quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry; $price = $quote['05. price'] ?? null; if (!is_numeric($price)) { return null; } return [ 'symbol' => trim((string) ($quote['01. symbol'] ?? '')), 'price' => (float) $price, 'currency' => '', 'fetched_at' => gmdate('Y-m-d H:i:s'), 'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')), 'source' => 'alphavantage:global_quote', 'raw' => $quote, ]; }); $mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array { $symbol = strtoupper(trim($symbol)); if ($symbol === '') { return [ 'ok' => false, 'message' => 'Kein Symbol hinterlegt.', ]; } $response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [ 'symbol' => $symbol, ]); if (empty($response['ok'])) { return $response; } $quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? [])); if (!is_array($quote)) { return [ 'ok' => false, 'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.', ]; } return ['ok' => true] + $quote; }); $mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array { $quotes = []; $errors = []; foreach ($instruments as $instrument) { if (!is_array($instrument)) { continue; } $instrumentId = (int) ($instrument['id'] ?? 0); $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); if ($instrumentId <= 0 || $symbol === '') { continue; } $result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); if (empty($result['ok'])) { $errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.'); continue; } $quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId]; } return [ 'ok' => true, 'quotes' => $quotes, 'errors' => $errors, 'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.', ]; }); $mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array { $pdo = module_fn('boersenchecker', 'pdo'); $settings = modules()->settings('boersenchecker'); $instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); $positionTable = module_fn('boersenchecker', 'table', 'positions'); $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); $defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; $minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); if ($minIntervalMinutes <= 0) { $minIntervalMinutes = 60; } $stmt = $pdo->query( 'SELECT DISTINCT i.id, i.name, i.symbol, i.quote_currency FROM ' . $positionTable . ' p INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id WHERE i.symbol IS NOT NULL AND i.symbol <> \'\' ORDER BY i.name ASC' ); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; if ($rows === []) { return [ 'ok' => true, 'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.', ]; } $instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows)); $instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0)); $latestQuotes = []; if ($instrumentIds !== []) { $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); $latestStmt = $pdo->prepare( 'SELECT * FROM ' . $quoteTable . ' WHERE instrument_id IN (' . $placeholders . ') AND source LIKE ? ORDER BY quoted_at DESC, created_at DESC, id DESC' ); $latestStmt->execute([...$instrumentIds, 'alphavantage:%']); foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { $instrumentId = (int) ($row['instrument_id'] ?? 0); if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) { $latestQuotes[$instrumentId] = $row; } } } $reused = 0; $candidates = []; foreach ($rows as $row) { $instrumentId = (int) ($row['id'] ?? 0); $latest = $latestQuotes[$instrumentId] ?? null; $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) { $reused++; continue; } $candidates[] = $row; } if ($candidates === []) { return [ 'ok' => true, 'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.', ]; } $bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates); if (empty($bulkResult['ok'])) { return [ 'ok' => false, 'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'), ]; } $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; $errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : []; $updated = 0; foreach ($candidates as $row) { $instrumentId = (int) ($row['id'] ?? 0); $quote = $quotes[$instrumentId] ?? null; if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { continue; } $storeResult = module_fn( 'boersenchecker', 'store_market_quote', $instrumentId, (float) $quote['price'], strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency, (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), (string) ($quote['source'] ?? 'alphavantage:global_quote') ); if (!empty($storeResult['inserted'])) { $updated++; } else { $reused++; } } $message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.'; if ($errors !== []) { $message .= ' ' . implode(' | ', array_slice($errors, 0, 3)); } module_debug_push('boersenchecker', [ 'label' => 'Intervall-Aufgabe', 'type' => 'scheduler:run', 'task' => 'auto_refresh_quotes', 'context' => $context, 'message' => $message, ]); return [ 'ok' => $errors === [], 'message' => $message, 'updated' => $updated, 'reused' => $reused, 'errors' => $errors, ]; }); $mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array { $keywords = trim($keywords); if ($keywords === '') { return [ 'ok' => false, 'message' => 'Bitte Suchbegriff angeben.', 'results' => [], ]; } $response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [ 'keywords' => $keywords, ]); if (empty($response['ok'])) { return $response + ['results' => []]; } $data = is_array($response['data'] ?? null) ? $response['data'] : []; $items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : []; $results = []; foreach ($items as $item) { if (!is_array($item)) { continue; } $symbol = trim((string) ($item['1. symbol'] ?? '')); $name = trim((string) ($item['2. name'] ?? '')); $type = trim((string) ($item['3. type'] ?? '')); $region = trim((string) ($item['4. region'] ?? '')); $currency = strtoupper(trim((string) ($item['8. currency'] ?? ''))); $matchScore = trim((string) ($item['9. matchScore'] ?? '')); if ($symbol === '' && $name === '') { continue; } $results[] = [ 'symbol' => $symbol, 'name' => $name, 'isin' => '', 'type' => $type, 'region' => $region, 'currency' => $currency, 'match_score' => $matchScore, 'raw' => $item, ]; } return [ 'ok' => true, 'message' => count($results) . ' Treffer gefunden.', 'results' => $results, ]; }); $mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array { $symbol = strtoupper(trim($symbol)); if ($symbol === '') { return ['ok' => false, 'message' => 'Kein Symbol angegeben.']; } $cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage'; if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); } $cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json'; $decoded = null; if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) { $cached = file_get_contents($cachePath); $decoded = is_string($cached) ? json_decode($cached, true) : null; } if (!is_array($decoded)) { $response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [ 'symbol' => $symbol, 'outputsize' => 'full', ]); if (empty($response['ok'])) { return $response; } $decoded = is_array($response['data'] ?? null) ? $response['data'] : []; @file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } $rows = is_array($decoded['Time Series (Daily)'] ?? null) ? $decoded['Time Series (Daily)'] : (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []); $daily = []; foreach ($rows as $date => $row) { if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) { continue; } $close = $row['5. adjusted close'] ?? $row['4. close'] ?? null; if (!is_numeric($close)) { continue; } $daily[] = [ 'date' => $date, 'close' => (float) $close, ]; } usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); if ($daily === []) { return [ 'ok' => false, 'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.', ]; } $aggregate = static function (array $points, string $format): array { $result = []; foreach ($points as $point) { $bucket = date($format, strtotime((string) $point['date']) ?: time()); $result[$bucket] = $point; } return array_values($result); }; return [ 'ok' => true, 'symbol' => $symbol, 'daily' => $daily, 'weekly' => $aggregate($daily, 'o-W'), 'monthly' => $aggregate($daily, 'Y-m'), 'source' => 'alphavantage:time_series_daily_adjusted', ]; }); $mm->registerFunction($moduleName, 'store_market_quote', static function ( int $instrumentId, float $price, string $currency, string $quotedAt, string $source ): array { $pdo = module_fn('boersenchecker', 'pdo'); $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); $quotedAt = trim($quotedAt); $currency = strtoupper(trim($currency)) ?: 'EUR'; $source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote'; $checkStmt = $pdo->prepare( 'SELECT id FROM ' . $quoteTable . ' WHERE instrument_id = :instrument_id AND price = :price AND currency = :currency AND quoted_at = :quoted_at AND source = :source LIMIT 1' ); $checkStmt->execute([ 'instrument_id' => $instrumentId, 'price' => $price, 'currency' => $currency, 'quoted_at' => $quotedAt, 'source' => $source, ]); $existingId = (int) $checkStmt->fetchColumn(); if ($existingId > 0) { module_debug_push('boersenchecker', [ 'label' => 'Quote Store', 'type' => 'quote:reuse', 'instrument_id' => $instrumentId, 'price' => $price, 'currency' => $currency, 'quoted_at' => $quotedAt, 'source' => $source, 'message' => 'Identischer Snapshot bereits vorhanden.', ]); return ['ok' => true, 'inserted' => false, 'id' => $existingId]; } $insertStmt = $pdo->prepare( 'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source) VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' ); $insertStmt->execute([ 'instrument_id' => $instrumentId, 'price' => $price, 'currency' => $currency, 'quoted_at' => $quotedAt, 'source' => $source, ]); $insertedId = (int) $pdo->lastInsertId(); module_debug_push('boersenchecker', [ 'label' => 'Quote Store', 'type' => 'quote:insert', 'instrument_id' => $instrumentId, 'price' => $price, 'currency' => $currency, 'quoted_at' => $quotedAt, 'source' => $source, 'inserted_id' => $insertedId, 'message' => 'Neuer Snapshot gespeichert.', ]); return ['ok' => true, 'inserted' => true, 'id' => $insertedId]; });