diff --git a/modules/boersenchecker/assets/boersenchecker.js b/modules/boersenchecker/assets/boersenchecker.js index c108095..5a098aa 100644 --- a/modules/boersenchecker/assets/boersenchecker.js +++ b/modules/boersenchecker/assets/boersenchecker.js @@ -115,7 +115,7 @@ } currentPayload = payload; renderChart(pointsForRange(payload, activeRange)); - if (statusNode) statusNode.textContent = `Quelle: Bavest | ISIN ${payload.isin || ''}`; + if (statusNode) statusNode.textContent = `Quelle: Alpha Vantage | Symbol ${payload.symbol || ''}`; } catch (error) { currentPayload = null; chartShell.innerHTML = `
${error.message}
`; diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php index 1779560..ed6cd28 100644 --- a/modules/boersenchecker/bootstrap.php +++ b/modules/boersenchecker/bootstrap.php @@ -277,67 +277,39 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu } }); -$mm->registerFunction($moduleName, 'bavest_request', static function ( - string $path, - array $payload = [], - string $accept = 'application/json', - string $method = 'POST' +$mm->registerFunction($moduleName, 'alpha_vantage_request', static function ( + string $functionName, + array $params = [] ): array { $settings = modules()->settings('boersenchecker'); - $apiKey = trim((string) ($settings['bavest_api_key'] ?? '')); - $timeout = (int) ($settings['bavest_timeout_sec'] ?? 12); + $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); + $timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 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', + 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ - 'method' => $method, - 'path' => $path, - 'payload' => $payload, + 'function' => $functionName, + 'params' => $params, ], - 'message' => 'Bavest-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', ]); return [ 'ok' => false, - 'message' => 'Bavest-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + 'message' => 'Alpha-Vantage-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'; - } + $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) { @@ -346,12 +318,8 @@ $mm->registerFunction($moduleName, 'bavest_request', static function ( CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min(5, $timeout), - CURLOPT_HTTPHEADER => $headers, + CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); - 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); @@ -362,38 +330,33 @@ $mm->registerFunction($moduleName, 'bavest_request', static function ( if (!is_string($responseBody) || $responseBody === '') { $context = stream_context_create([ 'http' => [ - 'method' => $method, + 'method' => 'GET', 'timeout' => $timeout, - 'header' => implode("\r\n", $headers) . "\r\n", + 'header' => "Accept: application/json\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', + 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ - 'method' => $method, + 'function' => $functionName, 'url' => $url, - 'payload' => $payload, + 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, 'curl_error' => $curlError, 'body' => null, ], - 'message' => 'Bavest Anfrage fehlgeschlagen.', + 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.', ]); return [ 'ok' => false, - 'message' => 'Bavest Anfrage fehlgeschlagen.' + 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.' . ($curlError !== '' ? ' ' . $curlError : '') . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), ]; @@ -402,35 +365,35 @@ $mm->registerFunction($moduleName, 'bavest_request', static function ( $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { module_debug_push('boersenchecker', [ - 'label' => 'Bavest Request', + 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ - 'method' => $method, + 'function' => $functionName, 'url' => $url, - 'payload' => $payload, + 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, 'body_preview' => substr($responseBody, 0, 4000), ], - 'message' => 'Bavest Antwort ist kein gueltiges JSON.', + 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', ]); return [ 'ok' => false, - 'message' => 'Bavest Antwort ist kein gueltiges JSON.', + 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', 'raw_body' => $responseBody, ]; } - foreach (['error', 'message', 'detail'] as $errorKey) { + foreach (['Error Message', 'Information', 'Note'] as $errorKey) { if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') { module_debug_push('boersenchecker', [ - 'label' => 'Bavest Request', + 'label' => 'Alpha Vantage Request', 'type' => 'api:error', 'request' => [ - 'method' => $method, + 'function' => $functionName, 'url' => $url, - 'payload' => $payload, + 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, @@ -447,12 +410,12 @@ $mm->registerFunction($moduleName, 'bavest_request', static function ( } module_debug_push('boersenchecker', [ - 'label' => 'Bavest Request', + 'label' => 'Alpha Vantage Request', 'type' => 'api:response', 'request' => [ - 'method' => $method, + 'function' => $functionName, 'url' => $url, - 'payload' => $payload, + 'params' => $params, ], 'response' => [ 'http_code' => $httpCode, @@ -470,7 +433,7 @@ $mm->registerFunction($moduleName, 'display_timezone', static function (): \Date return new \DateTimeZone('Europe/Berlin'); }); -$mm->registerFunction($moduleName, 'normalize_bavest_timestamp_utc', static function (mixed $value): string { +$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); } @@ -502,7 +465,7 @@ $mm->registerFunction($moduleName, 'format_datetime_for_display', static functio $displayTimezone = new \DateTimeZone('Europe/Berlin'); $source = trim((string) $source); - if (str_starts_with($source, 'bavest:')) { + 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 { @@ -529,155 +492,82 @@ $mm->registerFunction($moduleName, 'local_now_input_value', static function (): 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]; - } +$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; } - 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; + 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, 'bavest_fetch_quote_by_isin', static function (string $isin): array { - $isin = strtoupper(trim($isin)); - if ($isin === '') { +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array { + $symbol = strtoupper(trim($symbol)); + if ($symbol === '') { return [ 'ok' => false, - 'message' => 'Keine ISIN hinterlegt.', + 'message' => 'Kein Symbol hinterlegt.', ]; } - $response = module_fn('boersenchecker', 'bavest_request', 'timeseries/quote', ['isin' => $isin], 'application/json', 'GET'); + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [ + 'symbol' => $symbol, + ]); 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); + $quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? [])); if (!is_array($quote)) { return [ 'ok' => false, - 'message' => 'Bavest lieferte keinen Preis fuer die ISIN ' . $isin . '.', + 'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.', ]; } return ['ok' => true] + $quote; }); -$mm->registerFunction($moduleName, 'bavest_fetch_bulk_quotes', static function (array $instruments): array { - $payloadSymbols = []; - $indexByIsin = []; +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array { + $quotes = []; + $errors = []; foreach ($instruments as $instrument) { if (!is_array($instrument)) { continue; } - $isin = strtoupper(trim((string) ($instrument['isin'] ?? ''))); - if ($isin === '') { + $instrumentId = (int) ($instrument['id'] ?? 0); + $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { 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)) { + $result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($result['ok'])) { + $errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.'); 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]; + + $quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId]; } return [ 'ok' => true, 'quotes' => $quotes, - 'message' => count($quotes) . ' Kurse aus Bavest Bulk geladen.', + 'errors' => $errors, + 'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.', ]; }); -$mm->registerFunction($moduleName, 'bavest_search_symbols', static function (string $keywords): array { +$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array { $keywords = trim($keywords); if ($keywords === '') { return [ @@ -687,60 +577,40 @@ $mm->registerFunction($moduleName, 'bavest_search_symbols', static function (str ]; } - $response = module_fn('boersenchecker', 'bavest_request', 'reference/search/aggregated', [ - 'q' => $keywords, - 'limit' => 25, - ], 'application/json', 'GET'); + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [ + 'keywords' => $keywords, + ]); 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']; - } - + $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; } - $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, - ], - ]; + $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 [ @@ -750,17 +620,17 @@ $mm->registerFunction($moduleName, 'bavest_search_symbols', static function (str ]; }); -$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.']; +$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-bavest'; + $cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage'; if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); } - $cachePath = $cacheDir . '/' . md5('historical-price|' . $isin) . '.json'; + $cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json'; $decoded = null; if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) { @@ -769,11 +639,10 @@ $mm->registerFunction($moduleName, 'bavest_fetch_chart_series', static function } 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'); + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [ + 'symbol' => $symbol, + 'outputsize' => 'full', + ]); if (empty($response['ok'])) { return $response; } @@ -781,40 +650,29 @@ $mm->registerFunction($moduleName, 'bavest_fetch_chart_series', static function @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; - } + $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'] : []); - $dailyByDate = []; - foreach ($rows as $row) { - if (!is_array($row)) { + $daily = []; + foreach ($rows as $date => $row) { + if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) { continue; } - $date = trim((string) ($row['date'] ?? $row['time'] ?? $row['timestamp'] ?? '')); - $close = $row['close'] ?? $row['price'] ?? $row['c'] ?? null; - if ($date === '' || !is_numeric($close)) { + $close = $row['5. adjusted close'] ?? $row['4. close'] ?? null; + if (!is_numeric($close)) { continue; } - $normalizedDate = date('Y-m-d', strtotime($date) ?: time()); - $dailyByDate[$normalizedDate] = [ - 'date' => date('Y-m-d', strtotime($date) ?: time()), + $daily[] = [ + 'date' => $date, '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.', + 'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.', ]; } @@ -829,11 +687,11 @@ $mm->registerFunction($moduleName, 'bavest_fetch_chart_series', static function return [ 'ok' => true, - 'isin' => $isin, + 'symbol' => $symbol, 'daily' => $daily, 'weekly' => $aggregate($daily, 'o-W'), 'monthly' => $aggregate($daily, 'Y-m'), - 'source' => 'bavest:timeseries/history', + 'source' => 'alphavantage:time_series_daily_adjusted', ]; }); @@ -849,7 +707,7 @@ $mm->registerFunction($moduleName, 'store_market_quote', static function ( $quotedAt = trim($quotedAt); $currency = strtoupper(trim($currency)) ?: 'EUR'; - $source = trim($source) !== '' ? trim($source) : 'bavest:quote'; + $source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote'; $checkStmt = $pdo->prepare( 'SELECT id diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json index 2d4bc25..c8b747d 100644 --- a/modules/boersenchecker/module.json +++ b/modules/boersenchecker/module.json @@ -15,9 +15,9 @@ { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, { "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." }, { "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber den Mining-Checker genutzt." }, - { "name": "bavest_api_key", "label": "Bavest API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Bavest." }, - { "name": "bavest_timeout_sec", "label": "Bavest Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." }, - { "name": "bavest_min_interval_minutes", "label": "Bavest Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Bavest-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." } + { "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." }, + { "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." }, + { "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." } ] }, "db_defaults": { diff --git a/modules/boersenchecker/pages/chart_data.php b/modules/boersenchecker/pages/chart_data.php index 808dc26..4f22f4c 100644 --- a/modules/boersenchecker/pages/chart_data.php +++ b/modules/boersenchecker/pages/chart_data.php @@ -37,12 +37,12 @@ if (!is_array($instrument)) { exit; } -$isin = strtoupper(trim((string) ($instrument['isin'] ?? ''))); -if ($isin === '') { - echo json_encode(['ok' => false, 'message' => 'Fuer diese Aktie ist keine ISIN hinterlegt.'], JSON_UNESCAPED_UNICODE); +$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); +if ($symbol === '') { + echo json_encode(['ok' => false, 'message' => 'Fuer diese Aktie ist kein Symbol hinterlegt.'], JSON_UNESCAPED_UNICODE); exit; } -$result = module_fn('boersenchecker', 'bavest_fetch_chart_series', $isin); +$result = module_fn('boersenchecker', 'alpha_vantage_fetch_chart_series', $symbol); echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; diff --git a/modules/boersenchecker/partials/dashboard.php b/modules/boersenchecker/partials/dashboard.php index f19882e..203dd70 100644 --- a/modules/boersenchecker/partials/dashboard.php +++ b/modules/boersenchecker/partials/dashboard.php @@ -81,7 +81,7 @@ angestossen und respektiert die dortige Max-Age-Logik.

- Aktienkurse werden ueber Bavest per ISIN abgerufen. Das Ticker-Symbol bleibt als Zusatzinformation erhalten. + Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.

@@ -96,7 +96,7 @@
- Bavest Mindestabstand: Min. + Alpha Vantage Mindestabstand: Min.
API-Key und Timeout werden ueber Modul-Setup gepflegt. @@ -199,7 +199,7 @@

Wertpapiersuche

-

Bavest-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.

+

Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.

diff --git a/modules/boersenchecker/partials/home.php b/modules/boersenchecker/partials/home.php index bfe6d98..1b966d4 100644 --- a/modules/boersenchecker/partials/home.php +++ b/modules/boersenchecker/partials/home.php @@ -68,7 +68,7 @@
Marktdaten
-

Aktuelle Kurse fuer das gewaehlte Depot ueber Bavest per ISIN abrufen.

+

Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.

diff --git a/modules/boersenchecker/partials/instruments.php b/modules/boersenchecker/partials/instruments.php index 4831a06..935a8c3 100644 --- a/modules/boersenchecker/partials/instruments.php +++ b/modules/boersenchecker/partials/instruments.php @@ -37,7 +37,7 @@

Wertpapiersuche

-

Bavest-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.

+

Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.

diff --git a/modules/boersenchecker/src/Support/DashboardPage.php b/modules/boersenchecker/src/Support/DashboardPage.php index 1e245a5..0eefdeb 100644 --- a/modules/boersenchecker/src/Support/DashboardPage.php +++ b/modules/boersenchecker/src/Support/DashboardPage.php @@ -49,7 +49,7 @@ final class DashboardPage $this->fxMaxAgeHours = 6.0; } - $this->marketDataMinIntervalMinutes = (int) ($this->moduleSettings['bavest_min_interval_minutes'] ?? 60); + $this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); if ($this->marketDataMinIntervalMinutes <= 0) { $this->marketDataMinIntervalMinutes = 60; } @@ -410,22 +410,31 @@ final class DashboardPage } $instrumentId = (int) $row['instrument_id']; - $isin = strtoupper(trim((string) ($row['isin'] ?? ''))); + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); - if ($isin === '') { - throw new RuntimeException('Fuer diese Aktie ist noch keine ISIN hinterlegt.'); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.'); } $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { - return 'Vorhandener Bavest-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; } - $apiResult = \module_fn('boersenchecker', 'bavest_fetch_quote_by_isin', $isin); + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); if (empty($apiResult['ok'])) { throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); } + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $displayTime = (string) \module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($apiResult['fetched_at'] ?? ''), + (string) ($apiResult['source'] ?? 'alphavantage:global_quote') + ); + return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.'; + } $storeResult = \module_fn( 'boersenchecker', @@ -437,9 +446,9 @@ final class DashboardPage (string) $apiResult['source'] ); if (!empty($storeResult['inserted'])) { - return 'Bavest-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; + return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; } - return 'Vorhandener Bavest-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; } private function refreshMarketDataAll(): string @@ -464,6 +473,7 @@ final class DashboardPage $fetched = 0; $reused = 0; + $stale = 0; $skipped = 0; $failed = 0; $errors = []; @@ -471,8 +481,8 @@ final class DashboardPage $bulkRows = []; foreach ($rows as $row) { $instrumentId = (int) ($row['id'] ?? 0); - $isin = strtoupper(trim((string) ($row['isin'] ?? ''))); - if ($instrumentId <= 0 || $isin === '') { + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { $skipped++; continue; } @@ -488,9 +498,9 @@ final class DashboardPage } if ($bulkRows !== []) { - $bulkResult = \module_fn('boersenchecker', 'bavest_fetch_bulk_quotes', $bulkRows); + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows); if (empty($bulkResult['ok'])) { - throw new RuntimeException((string) ($bulkResult['message'] ?? 'Bavest Bulk-Abruf fehlgeschlagen.')); + throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.')); } $bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; @@ -500,7 +510,12 @@ final class DashboardPage $apiResult = $bulkQuotes[$instrumentId] ?? null; if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) { $failed++; - $errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Bavest-Antwort.'; + $errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.'; + continue; + } + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $stale++; continue; } @@ -523,19 +538,19 @@ final class DashboardPage if ($errors !== []) { throw new RuntimeException( - 'Bavest: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $skipped . ' ohne ISIN, ' . $failed . ' Fehler. ' + 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. ' . implode(' | ', array_slice($errors, 0, 3)) ); } - return 'Bavest: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $skipped . ' ohne ISIN, ' . $failed . ' Fehler.'; + return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.'; } private function searchSymbol(): string { $keywords = trim((string) ($_POST['search_keywords'] ?? '')); $this->symbolSearchKeywords = $keywords; - $result = \module_fn('boersenchecker', 'bavest_search_symbols', $keywords); + $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords); $this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : []; if (empty($result['ok'])) { @@ -740,12 +755,27 @@ final class DashboardPage ); $stmt->execute([ 'instrument_id' => $instrumentId, - 'source' => 'bavest:%', + 'source' => 'alphavantage:%', ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $row : null; } + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } + private function upsertInstrument(array $payload): int { return $this->instrumentRegistry->save($payload); diff --git a/modules/boersenchecker/src/Support/HomePage.php b/modules/boersenchecker/src/Support/HomePage.php index c761e1d..760ee6b 100644 --- a/modules/boersenchecker/src/Support/HomePage.php +++ b/modules/boersenchecker/src/Support/HomePage.php @@ -26,7 +26,7 @@ final class HomePage $settings = \modules()->settings('boersenchecker'); $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; - $this->marketDataMinIntervalMinutes = (int) ($settings['bavest_min_interval_minutes'] ?? 60); + $this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); if ($this->marketDataMinIntervalMinutes <= 0) { $this->marketDataMinIntervalMinutes = 60; } @@ -136,11 +136,12 @@ final class HomePage $updated = 0; $reused = 0; + $stale = 0; $bulkCandidates = []; foreach ($rows as $row) { $instrumentId = (int) ($row['id'] ?? 0); - $isin = strtoupper(trim((string) ($row['isin'] ?? ''))); - if ($instrumentId <= 0 || $isin === '') { + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { continue; } @@ -155,7 +156,7 @@ final class HomePage } if ($bulkCandidates !== []) { - $bulkResult = \module_fn('boersenchecker', 'bavest_fetch_bulk_quotes', $bulkCandidates); + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates); $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; foreach ($bulkCandidates as $row) { $instrumentId = (int) ($row['id'] ?? 0); @@ -163,6 +164,11 @@ final class HomePage if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { continue; } + $latest = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) { + $stale++; + continue; + } $storeResult = \module_fn( 'boersenchecker', 'store_market_quote', @@ -170,7 +176,7 @@ final class HomePage (float) $quote['price'], strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), - (string) ($quote['source'] ?? 'bavest:quote') + (string) ($quote['source'] ?? 'alphavantage:global_quote') ); if (!empty($storeResult['inserted'])) { $updated++; @@ -180,7 +186,7 @@ final class HomePage } } - return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet.'; + return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.'; } private function fetchPortfolios(): array @@ -254,12 +260,27 @@ final class HomePage ); $stmt->execute([ 'instrument_id' => $instrumentId, - 'source' => 'bavest:%', + 'source' => 'alphavantage:%', ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $row : null; } + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } + private function buildSummary(array $positions): array { $invested = 0.0; diff --git a/modules/boersenchecker/src/Support/InstrumentPage.php b/modules/boersenchecker/src/Support/InstrumentPage.php index 3e3c384..890361a 100644 --- a/modules/boersenchecker/src/Support/InstrumentPage.php +++ b/modules/boersenchecker/src/Support/InstrumentPage.php @@ -214,15 +214,25 @@ final class InstrumentPage { $instrumentId = (int) ($_POST['instrument_id'] ?? 0); $instrument = $this->assertInstrumentAccessible($instrumentId); - $isin = strtoupper(trim((string) ($instrument['isin'] ?? ''))); - if ($isin === '') { - throw new RuntimeException('Fuer diese Aktie ist keine ISIN hinterlegt.'); + $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.'); } - $apiResult = \module_fn('boersenchecker', 'bavest_fetch_quote_by_isin', $isin); + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); if (empty($apiResult['ok'])) { throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); } + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $displayTime = (string) \module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($apiResult['fetched_at'] ?? ''), + (string) ($apiResult['source'] ?? 'alphavantage:global_quote') + ); + return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.'; + } $storeResult = \module_fn( 'boersenchecker', @@ -235,14 +245,14 @@ final class InstrumentPage ); return !empty($storeResult['inserted']) - ? 'Bavest-Kurs gespeichert.' - : 'Vorhandener Bavest-Snapshot wiederverwendet.'; + ? 'Alpha-Vantage-Kurs gespeichert.' + : 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.'; } private function searchSymbol(): string { $this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? '')); - $result = \module_fn('boersenchecker', 'bavest_search_symbols', $this->searchKeywords); + $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords); $this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : []; if (empty($result['ok'])) { throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.')); @@ -294,4 +304,37 @@ final class InstrumentPage return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); } } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } } diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index 8fa80a1..dfa7ef9 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -398,44 +398,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $moduleStatusPanel = null; -if ($moduleName === 'boersenchecker') { - $quotaApiKey = trim((string) ($current['bavest_api_key'] ?? '')); - if ($quotaApiKey === '') { - $moduleStatusPanel = [ - 'title' => 'Bavest Quota', - 'type' => 'hint', - 'text' => 'Kein Bavest API Key hinterlegt. Nach dem Speichern wird hier die aktuelle Quota automatisch angezeigt.', - 'stats' => [], - ]; - } else { - $quotaTimeout = (int) ($current['bavest_timeout_sec'] ?? 10); - $quotaTimeout = $quotaTimeout > 0 ? $quotaTimeout : 10; - $quotaResult = $fetchJsonWithApiKey('https://api.bavest.co/v2/account/quota', $quotaApiKey, $quotaTimeout); - - if (!empty($quotaResult['ok'])) { - $quotaData = is_array($quotaResult['data']['data'] ?? null) ? $quotaResult['data']['data'] : []; - $moduleStatusPanel = [ - 'title' => 'Bavest Quota', - 'type' => 'success', - 'text' => 'Quota erfolgreich geprueft.', - 'stats' => [ - ['label' => 'Periode', 'value' => (string) ($quotaData['period'] ?? '-')], - ['label' => 'Verbrauch', 'value' => (string) ($quotaData['usage'] ?? '0')], - ['label' => 'Limit', 'value' => ($quotaData['limit'] ?? null) !== null ? (string) $quotaData['limit'] : 'unbegrenzt'], - ['label' => 'Verbleibend', 'value' => ($quotaData['remaining'] ?? null) !== null ? (string) $quotaData['remaining'] : 'unbegrenzt'], - ['label' => 'API-Key ID', 'value' => (string) ($quotaData['apiKey'] ?? '-')], - ], - ]; - } else { - $moduleStatusPanel = [ - 'title' => 'Bavest Quota', - 'type' => 'error', - 'text' => 'Quota konnte nicht geprueft werden: ' . (string) ($quotaResult['message'] ?? 'Unbekannter Fehler'), - 'stats' => [], - ]; - } - } -} $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) ? $testGroup