boersenchecker
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-22 00:40:13 +02:00
parent 1d948f0508
commit 04a4c3f2c1
6 changed files with 1617 additions and 29 deletions

3
api key Normal file
View File

@@ -0,0 +1,3 @@
API Key Alphavantage.co
NEMNJQYA35TJ1W9R

View File

@@ -0,0 +1,402 @@
<?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, 'alpha_vantage_fetch_quote', static function (string $symbol): array {
$settings = modules()->settings('boersenchecker');
$apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? ''));
$timeout = (int) ($settings['alpha_vantage_timeout_sec'] ?? 12);
$timeout = $timeout > 0 ? $timeout : 12;
$symbol = strtoupper(trim($symbol));
if ($symbol === '') {
return [
'ok' => false,
'message' => 'Kein API-Symbol hinterlegt.',
];
}
if ($apiKey === '') {
return [
'ok' => false,
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
];
}
$url = 'https://www.alphavantage.co/query?' . http_build_query([
'function' => 'GLOBAL_QUOTE',
'symbol' => $symbol,
'apikey' => $apiKey,
]);
$responseBody = null;
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 === '') {
return [
'ok' => false,
'message' => 'Alpha Vantage Anfrage fehlgeschlagen.'
. ($curlError !== '' ? ' ' . $curlError : '')
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
];
}
}
}
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 === '') {
return [
'ok' => false,
'message' => 'Alpha Vantage Anfrage lieferte keine Daten.',
];
}
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return [
'ok' => false,
'message' => 'Alpha Vantage Antwort ist kein gueltiges JSON.',
];
}
if (!empty($decoded['Note'])) {
return [
'ok' => false,
'message' => 'Alpha Vantage Limit-Hinweis: ' . trim((string) $decoded['Note']),
];
}
if (!empty($decoded['Information'])) {
return [
'ok' => false,
'message' => trim((string) $decoded['Information']),
];
}
if (!empty($decoded['Error Message'])) {
return [
'ok' => false,
'message' => trim((string) $decoded['Error Message']),
];
}
$quote = is_array($decoded['Global Quote'] ?? null) ? $decoded['Global Quote'] : [];
$price = $quote['05. price'] ?? null;
if (!is_numeric($price)) {
return [
'ok' => false,
'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.',
];
}
return [
'ok' => true,
'symbol' => (string) ($quote['01. symbol'] ?? $symbol),
'price' => (float) $price,
'latest_trading_day' => (string) ($quote['07. latest trading day'] ?? ''),
'previous_close' => is_numeric($quote['08. previous close'] ?? null) ? (float) $quote['08. previous close'] : null,
'change' => is_numeric($quote['09. change'] ?? null) ? (float) $quote['09. change'] : null,
'change_percent' => (string) ($quote['10. change percent'] ?? ''),
'fetched_at' => date('Y-m-d H:i:s'),
'source' => 'alpha_vantage:GLOBAL_QUOTE',
'raw' => $quote,
];
});

View File

@@ -1,21 +1,44 @@
{
"title": "Börsenchecker",
"version": "0.1.0",
"description": "Grundgeruest fuer ein Nexus-Modul zur Beobachtung und Auswertung von Boersenwerten.",
"version": "0.2.0",
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
"enabled_by_default": false,
"menu": [
{ "label": "Übersicht", "href": "/module/boersenchecker" }
{ "label": "Uebersicht", "href": "/module/boersenchecker" }
],
"sidebar": {
"enabled": true,
"collapsible": true,
"default": "collapsed",
"items": [
{ "label": "Übersicht", "href": "/module/boersenchecker" }
{ "label": "Uebersicht", "href": "/module/boersenchecker" }
]
},
"setup": {
"fields": []
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "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": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe ueber GLOBAL_QUOTE." },
{ "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": {
"driver": "pgsql",
"host": "localhost",
"port": 5432,
"dbname": "",
"schema": "public",
"user": "",
"password": ""
},
"auth": {
"required": true,

View File

@@ -1,28 +1,7 @@
<?php
declare(strict_types=1);
?>
<div class="card">
<div class="pill">Börsenchecker</div>
<h1 style="margin-top:.75rem;">Börsenchecker</h1>
<p class="muted">
Das Modul ist im Nexus registriert und kann jetzt schrittweise um Datenquellen,
Watchlists, Kennzahlen und Benachrichtigungen erweitert werden.
</p>
<div class="card" style="margin-top:1rem; background:var(--panel-2);">
<strong>Status</strong>
<div class="muted" style="margin-top:.5rem;">
Aktuell ist dies das initiale Grundgeruest des Moduls.
</div>
</div>
require_auth();
<div class="card" style="margin-top:1rem; background:var(--panel-2);">
<strong>Nächste sinnvolle Ausbaustufen</strong>
<ul style="margin:.75rem 0 0 1.1rem;">
<li>Watchlist fuer Ticker und ISINs</li>
<li>Kursdaten per API einlesen</li>
<li>Performance, Tagesdelta und historische Trends anzeigen</li>
<li>Alerts fuer Schwellwerte definieren</li>
</ul>
</div>
</div>
$page = new \Modules\Boersenchecker\Support\DashboardPage();
module_tpl('boersenchecker', 'dashboard', $page->handle());

View File

@@ -0,0 +1,388 @@
<div class="card">
<div class="pill">Boersenchecker</div>
<h1 style="margin-top:.75rem;">Depotverwaltung</h1>
<p class="muted">
Depots, Positionen und manuelle Kursverlaeufe. Die Waehrungsumrechnung nutzt, sofern verfuegbar,
die bestehende FX-Logik des Mining-Checkers weiter.
</p>
<?php if ($error): ?>
<div class="card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="grid" style="margin-top:1rem;">
<div class="card" style="background:var(--panel-2);">
<strong><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></strong>
<form method="post" style="margin-top:.75rem; display:grid; gap:10px;">
<input type="hidden" name="action" value="save_portfolio">
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depotname</span>
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Berichtswahrung</span>
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
</label>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="cta-button" type="submit">Depot speichern</button>
<?php if ($editPortfolio): ?>
<a class="nav-link" href="/module/boersenchecker">Abbrechen</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="card" style="background:var(--panel-2);">
<strong>API / FX</strong>
<p class="muted" style="margin-top:.75rem;">
Die Umrechnung liest gespeicherte FX-Daten aus dem Mining-Checker. Eine Aktualisierung wird nur manuell
angestossen und respektiert die dortige Max-Age-Logik.
</p>
<p class="muted" style="margin-top:.75rem;">
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten API-Symbols / Tickers pro Aktie abgerufen.
</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:1rem;">
<form method="post">
<input type="hidden" name="action" value="refresh_fx">
<button class="cta-button" type="submit">FX-Daten aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="action" value="refresh_alpha_vantage_all">
<button class="nav-link" type="submit">Alle API-Kurse abrufen</button>
</form>
</div>
<div class="muted" style="margin-top:.75rem;">
Alpha Vantage Mindestabstand: <?= e((string) $alphaMinIntervalMinutes) ?> Min.
</div>
<div class="muted" style="margin-top:.35rem;">
API-Key und Timeout werden ueber <a class="nav-link" href="/modules/setup/boersenchecker">Modul-Setup</a> gepflegt.
</div>
<div class="muted" style="margin-top:.75rem;">
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
</div>
</div>
</div>
<div class="card" style="margin-top:1rem; background:var(--panel-2);">
<strong><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></strong>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:.75rem;">Bitte zuerst ein Depot anlegen.</div>
<?php else: ?>
<form method="post" style="margin-top:.75rem; display:grid; gap:10px;">
<input type="hidden" name="action" value="save_position">
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depot</span>
<select name="portfolio_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktienname</span>
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>API-Symbol / Ticker</span>
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
</label>
<label class="setup-field muted">
<span>ISIN</span>
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>WKN</span>
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Boerse / Markt</span>
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Kurswaehrung</span>
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Stueckzahl</span>
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufpreis</span>
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufwaehrung</span>
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufdatum</span>
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
</label>
<label class="setup-field muted">
<span>Gebuehren</span>
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
</label>
</div>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
</label>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="cta-button" type="submit">Position speichern</button>
<?php if ($editPosition): ?>
<a class="nav-link" href="/module/boersenchecker">Abbrechen</a>
<?php endif; ?>
</div>
</form>
<?php endif; ?>
</div>
<div class="card" style="margin-top:1rem; background:var(--panel-2);">
<strong>Manuellen Kurs erfassen</strong>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:.75rem;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
<?php else: ?>
<form method="post" style="margin-top:.75rem; display:grid; gap:10px;">
<input type="hidden" name="action" value="save_quote">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktie</span>
<select name="quote_instrument_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($instrumentList as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field muted">
<span>Kurs</span>
<input type="number" name="quote_price" min="0" step="0.00000001" required>
</label>
<label class="setup-field muted">
<span>Waehrung</span>
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
</label>
<label class="setup-field muted">
<span>Zeitpunkt</span>
<input type="datetime-local" name="quoted_at" value="<?= e(date('Y-m-d\TH:i')) ?>" required>
</label>
<label class="setup-field muted">
<span>Quelle</span>
<input type="text" name="quote_source" value="manual">
</label>
</div>
<div>
<button class="cta-button" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</div>
<div style="margin-top:1rem;" class="grid">
<?php if ($portfolios === []): ?>
<div class="card">
<div class="muted">Noch keine Depots vorhanden.</div>
</div>
<?php else: ?>
<?php foreach ($portfolios as $portfolio): ?>
<?php
$portfolioId = (int) $portfolio['id'];
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
?>
<div class="card" style="background:var(--panel-2);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $portfolio['name']) ?></strong>
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<a class="nav-link" href="/module/boersenchecker?edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
<input type="hidden" name="action" value="delete_portfolio">
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
<button class="nav-link" type="submit">Loeschen</button>
</form>
</div>
</div>
<?php if (!empty($portfolio['notes'])): ?>
<div class="muted" style="margin-top:.65rem;"><?= e((string) $portfolio['notes']) ?></div>
<?php endif; ?>
<div class="grid" style="margin-top:1rem; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
<div class="card">
<div class="muted">Investiert</div>
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="card">
<div class="muted">Aktuell</div>
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="card">
<div class="muted">Gewinn / Verlust</div>
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="card" style="margin-top:1rem;">
<strong>Positionen</strong>
<?php if ($positions === []): ?>
<div class="muted" style="margin-top:.75rem;">Noch keine Positionen vorhanden.</div>
<?php else: ?>
<div style="overflow:auto; margin-top:.75rem;">
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr style="text-align:left; border-bottom:1px solid var(--border);">
<th style="padding:8px;">Depot</th>
<th style="padding:8px;">Aktie</th>
<th style="padding:8px;">ISIN / WKN</th>
<th style="padding:8px;">Stueck</th>
<th style="padding:8px;">Kauf</th>
<th style="padding:8px;">Letzter Kurs</th>
<th style="padding:8px;">Wert</th>
<th style="padding:8px;">Delta</th>
<th style="padding:8px;">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($positions as $position): ?>
<tr style="border-bottom:1px solid var(--border); vertical-align:top;">
<td style="padding:8px;"><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
<td style="padding:8px;">
<strong><?= e((string) $position['instrument_name']) ?></strong>
<?php if (!empty($position['symbol'])): ?>
<div class="muted"><?= e((string) $position['symbol']) ?></div>
<?php endif; ?>
</td>
<td style="padding:8px;">
<?= e((string) ($position['isin'] ?: '-')) ?>
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
</td>
<td style="padding:8px;"><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
<td style="padding:8px;">
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
</td>
<td style="padding:8px;">
<?php if ($position['latest_price'] !== null): ?>
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
<div class="muted"><?= e((string) $position['latest_quoted_at']) ?></div>
<?php else: ?>
<span class="muted">kein Kurs</span>
<?php endif; ?>
</td>
<td style="padding:8px;">
<?php if ($position['current_total_base'] !== null): ?>
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td style="padding:8px;">
<?php if ($position['gain_base'] !== null): ?>
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td style="padding:8px;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="nav-link" href="/module/boersenchecker?edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
<a class="nav-link" href="/module/boersenchecker?instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
<form method="post">
<input type="hidden" name="action" value="refresh_alpha_vantage_position">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="nav-link" type="submit">API-Kurs</button>
</form>
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
<input type="hidden" name="action" value="delete_position">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="nav-link" type="submit">Loeschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<div class="card" style="margin-top:1rem;">
<strong>Kursverlauf</strong>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:.75rem;">Noch keine Kursdaten vorhanden.</div>
<?php else: ?>
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
<div class="card" style="margin-top:1rem; background:var(--panel-2);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $instrument['name']) ?></strong>
<div class="muted">
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
</div>
</div>
<a class="nav-link" href="/module/boersenchecker?instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
</div>
<?php if ($history === []): ?>
<div class="muted" style="margin-top:.75rem;">Noch keine historischen Kurse vorhanden.</div>
<?php else: ?>
<div style="overflow:auto; margin-top:.75rem;">
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr style="text-align:left; border-bottom:1px solid var(--border);">
<th style="padding:8px;">Zeitpunkt</th>
<th style="padding:8px;">Kurs</th>
<th style="padding:8px;">Quelle</th>
<th style="padding:8px;">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($history as $quote): ?>
<tr style="border-bottom:1px solid var(--border);">
<td style="padding:8px;"><?= e((string) $quote['quoted_at']) ?></td>
<td style="padding:8px;"><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
<td style="padding:8px;"><?= e((string) $quote['source']) ?></td>
<td style="padding:8px;">
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="nav-link" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,793 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class DashboardPage
{
private PDO $pdo;
private array $user;
private string $ownerSub;
private array $moduleSettings;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $alphaMinIntervalMinutes;
private int $editPortfolioId;
private int $editPositionId;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$this->user = \auth_user() ?? [];
$this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
$this->moduleSettings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR'));
$this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$this->alphaMinIntervalMinutes = (int) ($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? 60);
if ($this->alphaMinIntervalMinutes <= 0) {
$this->alphaMinIntervalMinutes = 60;
}
$this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0);
$this->editPositionId = (int) ($_GET['edit_position'] ?? 0);
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->portfolioTable = $table('portfolios');
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$notice = $this->handlePost();
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$state = $this->loadState();
if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) {
$notice = $state['notice_override'];
}
if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) {
$error = $state['error_override'];
}
return [
'notice' => $notice,
'error' => $error,
'defaultReportCurrency' => $this->defaultReportCurrency,
'fxMaxAgeHours' => $this->fxMaxAgeHours,
'alphaMinIntervalMinutes' => $this->alphaMinIntervalMinutes,
'editPortfolio' => $state['editPortfolio'],
'editPosition' => $state['editPosition'],
'portfolios' => $state['portfolios'],
'portfolioById' => $state['portfolioById'],
'portfolioStats' => $state['portfolioStats'],
'positions' => $state['positions'],
'instrumentList' => $state['instrumentList'],
'quoteHistory' => $state['quoteHistory'],
'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'],
'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'],
'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale),
];
}
private function handlePost(): string
{
$action = trim((string) ($_POST['action'] ?? ''));
return match ($action) {
'save_portfolio' => $this->savePortfolio(),
'delete_portfolio' => $this->deletePortfolio(),
'save_position' => $this->savePosition(),
'delete_position' => $this->deletePosition(),
'save_quote' => $this->saveQuote(),
'refresh_alpha_vantage_position' => $this->refreshAlphaVantagePosition(),
'refresh_alpha_vantage_all' => $this->refreshAlphaVantageAll(),
'delete_quote' => $this->deleteQuote(),
'refresh_fx' => $this->refreshFx(),
default => '',
};
}
private function loadState(): array
{
$portfolios = $this->fetchPortfolios();
$positions = $this->fetchPositions();
$instrumentList = $this->buildInstrumentList($positions);
[$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList));
$portfolioById = $this->buildPortfolioById($portfolios);
[$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes);
$editPortfolio = null;
if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) {
$editPortfolio = $portfolioById[$this->editPortfolioId];
}
$editPosition = null;
if ($this->editPositionId > 0) {
foreach ($positions as $position) {
if ((int) $position['id'] === $this->editPositionId) {
$editPosition = $position;
break;
}
}
}
$selectedInstrumentForQuote = $editPosition
? (int) $editPosition['instrument_id']
: (int) ($_GET['instrument_id'] ?? 0);
$selectedInstrumentQuoteCurrency = $this->defaultReportCurrency;
if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) {
$selectedInstrumentQuoteCurrency = $this->normalizeCurrency(
(string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency)
);
}
return [
'portfolios' => $portfolios,
'portfolioById' => $portfolioById,
'portfolioStats' => $portfolioStats,
'positions' => $positions,
'instrumentList' => $instrumentList,
'quoteHistory' => $quoteHistory,
'editPortfolio' => $editPortfolio,
'editPosition' => $editPosition,
'selectedInstrumentForQuote' => $selectedInstrumentForQuote,
'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency,
];
}
private function savePortfolio(): string
{
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$name = trim((string) ($_POST['portfolio_name'] ?? ''));
$baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency));
$notes = trim((string) ($_POST['portfolio_notes'] ?? ''));
if ($name === '') {
throw new RuntimeException('Bitte einen Depotnamen angeben.');
}
if ($portfolioId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->portfolioTable . '
SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND owner_sub = :owner_sub'
);
$stmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
'name' => $name,
'base_currency' => $baseCurrency,
'notes' => $notes !== '' ? $notes : null,
]);
return 'Depot aktualisiert.';
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes)
VALUES (:owner_sub, :name, :base_currency, :notes)'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'name' => $name,
'base_currency' => $baseCurrency,
'notes' => $notes !== '' ? $notes : null,
]);
return 'Depot angelegt.';
}
private function deletePortfolio(): string
{
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub');
$countStmt->execute([
'portfolio_id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
if ((int) $countStmt->fetchColumn() > 0) {
throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.');
}
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub');
$stmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
return 'Depot geloescht.';
}
private function savePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$quantity = (float) ($_POST['quantity'] ?? 0);
$purchasePrice = (float) ($_POST['purchase_price'] ?? 0);
$purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency));
$purchaseDate = trim((string) ($_POST['purchase_date'] ?? ''));
$fees = trim((string) ($_POST['fees'] ?? ''));
$notes = trim((string) ($_POST['position_notes'] ?? ''));
if ($portfolioId <= 0) {
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
}
if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') {
throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.');
}
$portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
$portfolioOwnerStmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
if ($portfolioOwnerStmt->fetchColumn() === false) {
throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.');
}
$instrumentId = $this->upsertInstrument([
'id' => (int) ($_POST['instrument_id'] ?? 0),
'isin' => $_POST['isin'] ?? '',
'wkn' => $_POST['wkn'] ?? '',
'symbol' => $_POST['symbol'] ?? '',
'name' => $_POST['instrument_name'] ?? '',
'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency,
'market' => $_POST['market'] ?? '',
]);
$payload = [
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
'instrument_id' => $instrumentId,
'quantity' => $quantity,
'purchase_price' => $purchasePrice,
'purchase_currency' => $purchaseCurrency,
'purchase_date' => $purchaseDate,
'fees' => $fees !== '' ? (float) $fees : null,
'notes' => $notes !== '' ? $notes : null,
];
if ($positionId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->positionTable . '
SET portfolio_id = :portfolio_id,
instrument_id = :instrument_id,
quantity = :quantity,
purchase_price = :purchase_price,
purchase_currency = :purchase_currency,
purchase_date = :purchase_date,
fees = :fees,
notes = :notes,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND owner_sub = :owner_sub'
);
$stmt->execute($payload + ['id' => $positionId]);
return 'Position aktualisiert.';
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->positionTable . ' (
owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes
) VALUES (
:owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes
)'
);
$stmt->execute($payload);
return 'Position gespeichert.';
}
private function deletePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub');
$stmt->execute([
'id' => $positionId,
'owner_sub' => $this->ownerSub,
]);
return 'Position geloescht.';
}
private function saveQuote(): string
{
$instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0);
$price = (float) ($_POST['quote_price'] ?? 0);
$currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency));
$quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? ''));
$source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual';
if ($instrumentId <= 0 || $price <= 0) {
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
}
$this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source);
return 'Kurs gespeichert.';
}
private function refreshAlphaVantagePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$stmt = $this->pdo->prepare(
'SELECT
p.instrument_id,
i.name AS instrument_name,
i.symbol,
i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $positionId,
'owner_sub' => $this->ownerSub,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
throw new RuntimeException('Position nicht gefunden.');
}
$instrumentId = (int) $row['instrument_id'];
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
if ($symbol === '') {
throw new RuntimeException('Fuer diese Aktie ist noch kein API-Symbol / Ticker hinterlegt.');
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->alphaMinIntervalMinutes * 60)) {
return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
}
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol);
if (empty($apiResult['ok'])) {
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
}
$this->storeQuote(
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) $apiResult['source']
);
return 'API-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.';
}
private function refreshAlphaVantageAll(): string
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT
i.id,
i.name,
i.symbol,
i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub
ORDER BY i.name ASC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.');
}
$fetched = 0;
$reused = 0;
$skipped = 0;
$failed = 0;
$errors = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
if ($instrumentId <= 0 || $symbol === '') {
$skipped++;
continue;
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->alphaMinIntervalMinutes * 60)) {
$reused++;
continue;
}
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol);
if (empty($apiResult['ok'])) {
$failed++;
$errors[] = (string) ($row['name'] ?? $symbol) . ': ' . (string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.');
if (stripos((string) ($apiResult['message'] ?? ''), 'limit') !== false) {
break;
}
continue;
}
$this->storeQuote(
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) $apiResult['source']
);
$fetched++;
}
if ($errors !== []) {
throw new RuntimeException(
'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. '
. implode(' | ', array_slice($errors, 0, 3))
);
}
return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.';
}
private function deleteQuote(): string
{
$quoteId = (int) ($_POST['quote_id'] ?? 0);
$stmt = $this->pdo->prepare(
'DELETE FROM ' . $this->quoteTable . '
WHERE id = :id
AND instrument_id IN (
SELECT DISTINCT instrument_id
FROM ' . $this->positionTable . '
WHERE owner_sub = :owner_sub
)'
);
$stmt->execute([
'id' => $quoteId,
'owner_sub' => $this->ownerSub,
]);
return 'Kurseintrag geloescht.';
}
private function refreshFx(): string
{
$result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours);
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'FX-Daten aktualisiert.');
}
private function fetchPortfolios(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchPositions(): array
{
$stmt = $this->pdo->prepare(
'SELECT
p.*,
i.isin,
i.wkn,
i.symbol,
i.name AS instrument_name,
i.quote_currency,
i.market
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub
ORDER BY p.purchase_date DESC, p.id DESC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function buildInstrumentList(array $positions): array
{
$instrumentList = [];
foreach ($positions as $position) {
$instrumentId = (int) $position['instrument_id'];
if (!isset($instrumentList[$instrumentId])) {
$instrumentList[$instrumentId] = [
'id' => $instrumentId,
'name' => (string) $position['instrument_name'],
'symbol' => (string) ($position['symbol'] ?? ''),
'isin' => (string) ($position['isin'] ?? ''),
'quote_currency' => (string) ($position['quote_currency'] ?? ''),
];
}
}
return $instrumentList;
}
private function fetchQuotes(array $instrumentIds): array
{
$quoteHistory = [];
$latestQuotes = [];
if ($instrumentIds === []) {
return [$quoteHistory, $latestQuotes];
}
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$stmt->execute($instrumentIds);
$quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($quotes as $quote) {
$instrumentId = (int) $quote['instrument_id'];
$quoteHistory[$instrumentId][] = $quote;
if (!isset($latestQuotes[$instrumentId])) {
$latestQuotes[$instrumentId] = $quote;
}
}
return [$quoteHistory, $latestQuotes];
}
private function buildPortfolioById(array $portfolios): array
{
$portfolioById = [];
foreach ($portfolios as $portfolio) {
$portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency));
$portfolioById[(int) $portfolio['id']] = $portfolio;
}
return $portfolioById;
}
private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array
{
$portfolioStats = [];
foreach ($portfolioById as $portfolioId => $portfolio) {
$portfolioStats[$portfolioId] = [
'invested' => 0.0,
'current' => 0.0,
'gain' => 0.0,
'positions' => 0,
'has_invested' => false,
'has_current' => false,
];
}
foreach ($positions as &$position) {
$portfolioId = (int) $position['portfolio_id'];
$baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency);
$quantity = (float) $position['quantity'];
$purchasePrice = (float) $position['purchase_price'];
$fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0;
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
$purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency);
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
$currentTotalBase = null;
if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) {
$currentOriginal = $quantity * (float) $latestQuote['price'];
$currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency);
$position['latest_price'] = (float) $latestQuote['price'];
$position['latest_currency'] = (string) $latestQuote['currency'];
$position['latest_quoted_at'] = (string) $latestQuote['quoted_at'];
$position['current_total_base'] = $currentTotalBase;
} else {
$position['latest_price'] = null;
$position['latest_currency'] = null;
$position['latest_quoted_at'] = null;
$position['current_total_base'] = null;
}
$position['purchase_total'] = $purchaseTotal;
$position['purchase_total_base'] = $purchaseTotalBase;
$position['base_currency'] = $baseCurrency;
$position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null
? $currentTotalBase - $purchaseTotalBase
: null;
if (isset($portfolioStats[$portfolioId])) {
$portfolioStats[$portfolioId]['positions']++;
if ($purchaseTotalBase !== null) {
$portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase;
$portfolioStats[$portfolioId]['has_invested'] = true;
}
if ($currentTotalBase !== null) {
$portfolioStats[$portfolioId]['current'] += $currentTotalBase;
$portfolioStats[$portfolioId]['has_current'] = true;
}
}
}
unset($position);
foreach ($portfolioStats as &$stats) {
$stats['gain'] = ($stats['has_invested'] && $stats['has_current'])
? $stats['current'] - $stats['invested']
: null;
}
unset($stats);
return [$positions, $portfolioStats];
}
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' => 'alpha_vantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function upsertInstrument(array $payload): int
{
$instrumentId = (int) ($payload['id'] ?? 0);
$driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
$data = [
'isin' => trim((string) ($payload['isin'] ?? '')) ?: null,
'wkn' => trim((string) ($payload['wkn'] ?? '')) ?: null,
'symbol' => trim((string) ($payload['symbol'] ?? '')) ?: null,
'name' => trim((string) ($payload['name'] ?? '')),
'quote_currency' => $this->normalizeCurrency((string) ($payload['quote_currency'] ?? 'EUR')),
'market' => trim((string) ($payload['market'] ?? '')) ?: null,
];
if ($data['name'] === '') {
throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
}
if ($instrumentId <= 0) {
$instrumentId = $this->findInstrumentId($payload) ?? 0;
}
if ($instrumentId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->instrumentTable . '
SET isin = :isin, wkn = :wkn, symbol = :symbol, name = :name, quote_currency = :quote_currency, market = :market, updated_at = CURRENT_TIMESTAMP
WHERE id = :id'
);
$stmt->execute($data + ['id' => $instrumentId]);
return $instrumentId;
}
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
RETURNING id'
);
$stmt->execute($data);
return (int) $stmt->fetchColumn();
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
private function findInstrumentId(array $payload): ?int
{
$isin = trim((string) ($payload['isin'] ?? ''));
$symbol = trim((string) ($payload['symbol'] ?? ''));
$name = trim((string) ($payload['name'] ?? ''));
if ($isin !== '') {
$stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin LIMIT 1');
$stmt->execute(['isin' => $isin]);
$id = $stmt->fetchColumn();
if ($id !== false) {
return (int) $id;
}
}
if ($symbol !== '' && $name !== '') {
$stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name LIMIT 1');
$stmt->execute([
'symbol' => $symbol,
'name' => $name,
]);
$id = $stmt->fetchColumn();
if ($id !== false) {
return (int) $id;
}
}
return null;
}
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
}
private function convertAmount(?float $amount, string $from, string $to): ?float
{
if ($amount === null) {
return null;
}
$from = $this->normalizeCurrency($from);
$to = $this->normalizeCurrency($to);
if ($from === $to) {
return $amount;
}
$fxService = \module_fn('boersenchecker', 'fx_service');
if (!$fxService || !method_exists($fxService, 'convert')) {
return null;
}
try {
$value = $fxService->convert($amount, $from, $to);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
}
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
{
$normalized = strtoupper(trim((string) $value));
return $normalized !== '' ? $normalized : $fallback;
}
private function normalizeDateTimeLocal(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return date('Y-m-d H:i:s');
}
$timestamp = strtotime($value);
return $timestamp !== false ? date('Y-m-d H:i:s', $timestamp) : date('Y-m-d H:i:s');
}
private function formatNumber(?float $value, int $scale = 2): string
{
if ($value === null) {
return 'n/a';
}
return number_format($value, $scale, ',', '.');
}
}