presseportale/app/Services/Web/MarketDataService.php
Kevin Adametz 85d7c2b0d5 betrieb: Logs je Thema in Tagesdateien (billing, ki, monitoring, market); Doku: Duplicate-Content-Decision durch Anhang 2 abgelöst
- config/logging.php: Themen-Channels billing, ki, monitoring, market als
  daily-Dateien mit gemeinsamer Aufbewahrung (LOG_DAILY_DAYS), Stack-Default
  auf daily; Channel `testing` für die Suite.
- Fachliche Log-Aufrufe in Billing, KI-Jobs, OperationalAlert und
  Kursdaten auf Log::channel() umgestellt.
- phpunit.xml + TestCase: Suite schreibt ausschließlich nach
  testing-YYYY-MM-DD.log; LogChannelsTest sichert die Konfiguration.
- docs/weiteres: Decision-Update Duplicate-Content entfernt (überholt durch
  Roadmap-Anhang 2), Decision-Update Verlinkung & Backlinks als Referenz
  wieder abgelegt, README-Index nachgezogen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:35:42 +00:00

119 lines
4 KiB
PHP

<?php
namespace App\Services\Web;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Kursdaten für den Ad-Hoc-Ticker der Web-Frontends.
*
* refresh() fragt die konfigurierten Symbole (config/market.php) über die
* Yahoo-Finance-Chart-API ab und legt sie in den Cache; die Views lesen nur
* aus dem Cache (slides()). Schlägt der Abruf für alle Symbole fehl, bleibt
* der bestehende Cache-Stand erhalten, damit ein API-Ausfall die Anzeige
* nicht sofort leert.
*/
class MarketDataService
{
/**
* @return list<array{label: string, value: string, delta: string, positive: bool}>
*/
public function slides(): array
{
if (! config('market.enabled')) {
return [];
}
/** @var list<array{label: string, value: string, delta: string, positive: bool}> $slides */
$slides = Cache::get((string) config('market.cache_key'), []);
return is_array($slides) ? $slides : [];
}
/**
* @return list<array{label: string, value: string, delta: string, positive: bool}>
*/
public function refresh(): array
{
$slides = [];
foreach ((array) config('market.symbols', []) as $config) {
$quote = $this->fetchQuote((string) $config['symbol']);
if ($quote === null) {
continue;
}
[$price, $previousClose] = $quote;
$deltaPercent = $previousClose > 0.0
? ($price - $previousClose) / $previousClose * 100
: 0.0;
$slides[] = [
'label' => (string) $config['label'],
'value' => number_format($price, (int) ($config['precision'] ?? 2), ',', '.').($config['suffix'] ?? ''),
'delta' => ($deltaPercent >= 0 ? '+' : '').number_format($deltaPercent, 1, ',', '.').'%',
'positive' => $deltaPercent >= 0,
];
}
if ($slides === []) {
Log::channel('market')->warning('market:refresh lieferte keine Kursdaten — bestehender Cache-Stand bleibt erhalten.');
return $this->slides();
}
Cache::put((string) config('market.cache_key'), $slides, (int) config('market.cache_ttl', 86400));
return $slides;
}
/**
* @return array{0: float, 1: float}|null [Kurs, Vortagesschluss]
*/
private function fetchQuote(string $symbol): ?array
{
try {
$response = Http::timeout(10)
->connectTimeout(3)
->retry(
[150, 400],
when: static fn (Throwable $exception): bool => $exception instanceof ConnectionException
|| ($exception instanceof RequestException && in_array($exception->response->status(), [429, 500, 502, 503, 504], true)),
throw: false,
)
->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; PressekontoTicker/1.0)'])
->get(config('market.endpoint').rawurlencode($symbol), [
'range' => '1d',
'interval' => '1d',
]);
} catch (Throwable $exception) {
Log::channel('market')->warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'error' => $exception->getMessage()]);
return null;
}
if (! $response->successful()) {
Log::channel('market')->warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'status' => $response->status()]);
return null;
}
$meta = $response->json('chart.result.0.meta');
$price = $meta['regularMarketPrice'] ?? null;
$previousClose = $meta['chartPreviousClose'] ?? $meta['previousClose'] ?? null;
if (! is_numeric($price) || ! is_numeric($previousClose)) {
return null;
}
return [(float) $price, (float) $previousClose];
}
}