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, 'bavest_request', static function ( string $path, array $payload = [], string $accept = 'application/json', string $method = 'POST' ): array { $settings = modules()->settings('boersenchecker'); $apiKey = trim((string) ($settings['bavest_api_key'] ?? '')); $timeout = (int) ($settings['bavest_timeout_sec'] ?? 12); $timeout = $timeout > 0 ? $timeout : 12; $method = strtoupper(trim($method)); $method = in_array($method, ['GET', 'POST'], true) ? $method : 'POST'; if ($apiKey === '') { module_debug_push('boersenchecker', [ 'label' => 'Bavest Request', 'type' => 'api:error', 'request' => [ 'method' => $method, 'path' => $path, 'payload' => $payload, ], 'message' => 'Bavest-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', ]); return [ 'ok' => false, 'message' => 'Bavest-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', ]; } $url = 'https://api.bavest.co/v2/' . ltrim($path, '/'); $jsonPayload = ''; if ($method === 'GET') { if ($payload !== []) { $query = http_build_query($payload, '', '&', PHP_QUERY_RFC3986); if ($query !== '') { $url .= (str_contains($url, '?') ? '&' : '?') . $query; } } } else { $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($jsonPayload)) { return [ 'ok' => false, 'message' => 'Bavest-Payload konnte nicht kodiert werden.', ]; } } $headers = [ 'Accept: ' . $accept, 'x-api-key: ' . $apiKey, ]; if ($method === 'POST') { $headers[] = 'Content-Type: application/json'; } $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 => $headers, ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); } $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' => $method, 'timeout' => $timeout, 'header' => implode("\r\n", $headers) . "\r\n", ], ]); if ($method === 'POST') { $contextOptions = stream_context_get_options($context); $contextOptions['http']['content'] = $jsonPayload; $context = stream_context_create($contextOptions); } $responseBody = @file_get_contents($url, false, $context); } if (!is_string($responseBody) || $responseBody === '') { module_debug_push('boersenchecker', [ 'label' => 'Bavest Request', 'type' => 'api:error', 'request' => [ 'method' => $method, 'url' => $url, 'payload' => $payload, ], 'response' => [ 'http_code' => $httpCode, 'curl_error' => $curlError, 'body' => null, ], 'message' => 'Bavest Anfrage fehlgeschlagen.', ]); return [ 'ok' => false, 'message' => 'Bavest Anfrage fehlgeschlagen.' . ($curlError !== '' ? ' ' . $curlError : '') . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), ]; } $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { module_debug_push('boersenchecker', [ 'label' => 'Bavest Request', 'type' => 'api:error', 'request' => [ 'method' => $method, 'url' => $url, 'payload' => $payload, ], 'response' => [ 'http_code' => $httpCode, 'body_preview' => substr($responseBody, 0, 4000), ], 'message' => 'Bavest Antwort ist kein gueltiges JSON.', ]); return [ 'ok' => false, 'message' => 'Bavest Antwort ist kein gueltiges JSON.', 'raw_body' => $responseBody, ]; } foreach (['error', 'message', 'detail'] as $errorKey) { if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') { module_debug_push('boersenchecker', [ 'label' => 'Bavest Request', 'type' => 'api:error', 'request' => [ 'method' => $method, 'url' => $url, 'payload' => $payload, ], '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' => 'Bavest Request', 'type' => 'api:response', 'request' => [ 'method' => $method, 'url' => $url, 'payload' => $payload, ], '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_bavest_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:')) { $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, 'bavest_extract_quote', static function (array $entry): ?array { $candidates = [$entry]; foreach (['quote', 'data', 'result', 'security'] as $nestedKey) { if (isset($entry[$nestedKey]) && is_array($entry[$nestedKey])) { $candidates[] = $entry[$nestedKey]; } } foreach ($candidates as $candidate) { $price = null; foreach (['price', 'close', 'last', 'lastPrice', 'currentPrice', 'c'] as $priceKey) { if (is_numeric($candidate[$priceKey] ?? null)) { $price = (float) $candidate[$priceKey]; break; } } if ($price === null) { continue; } $timestamp = module_fn( 'boersenchecker', 'normalize_bavest_timestamp_utc', $candidate['timestamp'] ?? $candidate['time'] ?? $candidate['date'] ?? null ); return [ 'symbol' => trim((string) ($candidate['symbol'] ?? $candidate['ticker'] ?? $entry['symbol'] ?? '')), 'isin' => trim((string) ($candidate['isin'] ?? $entry['isin'] ?? '')), 'price' => $price, 'currency' => strtoupper(trim((string) ($candidate['currency'] ?? $candidate['quoteCurrency'] ?? $entry['currency'] ?? 'EUR'))) ?: 'EUR', 'fetched_at' => $timestamp, 'source' => 'bavest:quote', 'raw' => $candidate, ]; } return null; }); $mm->registerFunction($moduleName, 'bavest_fetch_quote_by_isin', static function (string $isin): array { $isin = strtoupper(trim($isin)); if ($isin === '') { return [ 'ok' => false, 'message' => 'Keine ISIN hinterlegt.', ]; } $response = module_fn('boersenchecker', 'bavest_request', 'timeseries/quote', ['isin' => $isin], 'application/json', 'GET'); if (empty($response['ok'])) { return $response; } $data = is_array($response['data'] ?? null) ? $response['data'] : []; if (isset($data['data']) && is_array($data['data'])) { $data = [ 'isin' => $isin, 'data' => $data['data'], ]; } else { $data['isin'] = $data['isin'] ?? $isin; } $quote = module_fn('boersenchecker', 'bavest_extract_quote', $data); if (!is_array($quote)) { return [ 'ok' => false, 'message' => 'Bavest lieferte keinen Preis fuer die ISIN ' . $isin . '.', ]; } return ['ok' => true] + $quote; }); $mm->registerFunction($moduleName, 'bavest_fetch_bulk_quotes', static function (array $instruments): array { $payloadSymbols = []; $indexByIsin = []; foreach ($instruments as $instrument) { if (!is_array($instrument)) { continue; } $isin = strtoupper(trim((string) ($instrument['isin'] ?? ''))); if ($isin === '') { continue; } $payloadSymbols[] = ['isin' => $isin]; $indexByIsin[$isin] = (int) ($instrument['id'] ?? 0); } if ($payloadSymbols === []) { return [ 'ok' => false, 'message' => 'Keine ISIN fuer den Bulk-Abruf verfuegbar.', 'quotes' => [], ]; } $response = module_fn('boersenchecker', 'bavest_request', 'bulk', [ 'symbols' => $payloadSymbols, 'endpoint' => 'quote', 'params' => new stdClass(), ], 'application/json', 'POST'); if (empty($response['ok'])) { return $response + ['quotes' => []]; } $data = $response['data'] ?? []; $items = []; if (is_array($data)) { if (isset($data['data']) && is_array($data['data'])) { $items = $data['data']; } elseif (isset($data['results']) && is_array($data['results'])) { $items = $data['results']; } elseif (array_is_list($data)) { $items = $data; } else { $items = [$data]; } } $quotes = []; foreach ($items as $offset => $item) { if (!is_array($item)) { continue; } $quote = module_fn('boersenchecker', 'bavest_extract_quote', $item); if (!is_array($quote)) { continue; } $isin = strtoupper(trim((string) ($quote['isin'] ?? $item['isin'] ?? ''))); $instrumentId = $isin !== '' ? ($indexByIsin[$isin] ?? 0) : 0; if ($instrumentId <= 0 && isset($payloadSymbols[$offset]['isin'])) { $instrumentId = (int) ($indexByIsin[(string) $payloadSymbols[$offset]['isin']] ?? 0); $quote['isin'] = (string) $payloadSymbols[$offset]['isin']; } if ($instrumentId <= 0) { continue; } $quotes[$instrumentId] = $quote + ['instrument_id' => $instrumentId]; } return [ 'ok' => true, 'quotes' => $quotes, 'message' => count($quotes) . ' Kurse aus Bavest Bulk geladen.', ]; }); $mm->registerFunction($moduleName, 'bavest_search_symbols', static function (string $keywords): array { $keywords = trim($keywords); if ($keywords === '') { return [ 'ok' => false, 'message' => 'Bitte Suchbegriff angeben.', 'results' => [], ]; } $response = module_fn('boersenchecker', 'bavest_request', 'reference/search/aggregated', [ 'q' => $keywords, 'limit' => 25, ], 'application/json', 'GET'); if (empty($response['ok'])) { return $response + ['results' => []]; } $data = $response['data'] ?? []; $items = []; if (is_array($data['data']['results'] ?? null)) { $items = $data['data']['results']; } elseif (is_array($data['results'] ?? null)) { $items = $data['results']; } $results = []; foreach ($items as $item) { if (!is_array($item)) { continue; } $name = trim((string) ($item['name'] ?? $item['companyName'] ?? $item['securityName'] ?? '')); $rootTicker = trim((string) ($item['root_ticker'] ?? '')); $listings = is_array($item['listings'] ?? null) ? $item['listings'] : []; foreach ($listings as $listing) { if (!is_array($listing)) { continue; } $symbol = trim((string) ($listing['symbol'] ?? $rootTicker)); $isin = strtoupper(trim((string) ($listing['isin'] ?? ''))); $region = trim((string) ($listing['exchange'] ?? $listing['region'] ?? '')); $type = trim((string) ($listing['type'] ?? '')); $currency = strtoupper(trim((string) ($listing['currency'] ?? ''))); if ($symbol === '' && $name === '' && $isin === '') { continue; } $results[] = [ 'symbol' => $symbol, 'name' => $name, 'isin' => $isin, 'type' => $type, 'region' => $region, 'currency' => $currency, 'match_score' => '', 'raw' => [ 'security' => $item, 'listing' => $listing, ], ]; } } return [ 'ok' => true, 'message' => count($results) . ' Treffer gefunden.', 'results' => $results, ]; }); $mm->registerFunction($moduleName, 'bavest_fetch_chart_series', static function (string $isin): array { $isin = strtoupper(trim($isin)); if ($isin === '') { return ['ok' => false, 'message' => 'Keine ISIN angegeben.']; } $cacheDir = sys_get_temp_dir() . '/boersenchecker-bavest'; if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); } $cachePath = $cacheDir . '/' . md5('historical-price|' . $isin) . '.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', 'bavest_request', 'timeseries/history', [ 'isin' => $isin, 'from' => date('Y-m-d', strtotime('-6 years')), 'to' => date('Y-m-d'), ], 'application/json', 'GET'); 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 = []; if (isset($decoded['data']['prices']) && is_array($decoded['data']['prices'])) { $rows = $decoded['data']['prices']; } elseif (isset($decoded['data']) && is_array($decoded['data'])) { $rows = $decoded['data']; } elseif (isset($decoded['results']) && is_array($decoded['results'])) { $rows = $decoded['results']; } elseif (array_is_list($decoded)) { $rows = $decoded; } $dailyByDate = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $date = trim((string) ($row['date'] ?? $row['time'] ?? $row['timestamp'] ?? '')); $close = $row['close'] ?? $row['price'] ?? $row['c'] ?? null; if ($date === '' || !is_numeric($close)) { continue; } $normalizedDate = date('Y-m-d', strtotime($date) ?: time()); $dailyByDate[$normalizedDate] = [ 'date' => date('Y-m-d', strtotime($date) ?: time()), 'close' => (float) $close, ]; } $daily = array_values($dailyByDate); 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 ' . $isin . ' 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, 'isin' => $isin, 'daily' => $daily, 'weekly' => $aggregate($daily, 'o-W'), 'monthly' => $aggregate($daily, 'Y-m'), 'source' => 'bavest:timeseries/history', ]; }); $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) : 'bavest: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]; });