Files
nexus/modules/boersenchecker/bootstrap.php
Lars Gebhardt-Kusche 739e4d4c42
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
debug
2026-04-24 23:12:19 +02:00

913 lines
33 KiB
PHP

<?php
declare(strict_types=1);
use App\ModuleConfigException;
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\MiningChecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = dirname(__DIR__) . '/mining-checker/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\Boersenchecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
$moduleName = 'boersenchecker';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->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];
});