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