Files
Stonks/stockspricetarget_yahoo.php
2026-07-24 22:40:49 +02:00

635 lines
23 KiB
PHP

<?php
// ============================================================
// stocks_widget.php - Uses exact symbols from JSON
// ============================================================
// ---- READ STOCKS JSON ----
$jsonFile = 'stocks.json';
if (!file_exists($jsonFile)) {
die('<div class="no-stocks">⚠️ stocks.json not found</div>');
}
$json = file_get_contents($jsonFile);
$data = json_decode($json, true);
if (!is_array($data)) {
die('<div class="no-stocks">⚠️ Invalid JSON format</div>');
}
// Filter stocks with pricetarget
$targetStocks = array_filter($data, function($item) {
return isset($item['pricetarget']) && is_numeric($item['pricetarget']) && $item['pricetarget'] > 0;
});
// ---- FETCH LIVE QUOTE DATA FROM YAHOO FINANCE ----
// Uses the public chart endpoint - no API key needed. Returns an array of
// everything useful for display + estimates, or null on failure.
function fetchQuoteData($symbol) {
$url = "https://query1.finance.yahoo.com/v8/finance/chart/"
. urlencode($symbol) . "?interval=1d&range=1y";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// Yahoo blocks requests without a browser-like User-Agent.
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr) {
error_log("[stocks_widget] fetchQuoteData($symbol) cURL error: $curlErr | URL: $url");
return null;
}
if ($httpCode !== 200 || !$response) {
error_log("[stocks_widget] fetchQuoteData($symbol) HTTP $httpCode | URL: $url | body: " . substr((string)$response, 0, 300));
return null;
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("[stocks_widget] fetchQuoteData($symbol) JSON decode error: " . json_last_error_msg());
return null;
}
$chartError = $data['chart']['error'] ?? null;
if ($chartError) {
$errMsg = is_array($chartError) ? ($chartError['description'] ?? json_encode($chartError)) : $chartError;
error_log("[stocks_widget] fetchQuoteData($symbol) Yahoo API error: $errMsg");
return null;
}
$result = $data['chart']['result'][0] ?? null;
$meta = $result['meta'] ?? null;
if (!$meta) {
error_log("[stocks_widget] fetchQuoteData($symbol) missing meta in response: " . substr($response, 0, 300));
return null;
}
$price = $meta['regularMarketPrice'] ?? null;
if ($price === null || $price <= 0) {
error_log("[stocks_widget] fetchQuoteData($symbol) no usable price in meta: " . substr($response, 0, 300));
return null;
}
// marketState: "PRE", "REGULAR", "POST", "POSTPOST", or "CLOSED"
$marketState = $meta['marketState'] ?? null;
if ($marketState === null) {
error_log("[stocks_widget] fetchQuoteData($symbol) no marketState in meta - market badge will be hidden");
}
if (empty($meta['previousClose'])) {
error_log("[stocks_widget] fetchQuoteData($symbol) no previousClose in meta - Today change will be hidden");
}
return [
'price' => $price,
// IMPORTANT: only use meta.previousClose here, never chartPreviousClose.
// chartPreviousClose is the close from BEFORE the whole requested range
// (with range=1y that's ~a year ago), not yesterday's close. Falling back
// to it silently produced nonsense day-change percentages like +50%/-90%.
'previous_close' => $meta['previousClose'] ?? null,
'market_state' => $marketState,
'high_52' => $meta['fiftyTwoWeekHigh'] ?? null,
'low_52' => $meta['fiftyTwoWeekLow'] ?? null,
];
}
// ---- FETCH ALL QUOTE DATA ----
$liveData = [];
$apiWorking = false;
foreach ($targetStocks as $stock) {
$symbol = $stock['stock'];
$quote = fetchQuoteData($symbol);
if ($quote !== null) {
$liveData[$symbol] = $quote;
$apiWorking = true;
}
}
// ---- PROCESS STOCKS ----
$processedStocks = [];
foreach ($targetStocks as $stock) {
$symbol = $stock['stock'];
// Buy price from JSON (what you paid)
$buyPrice = (float)$stock['price'];
$priceTarget = (float)$stock['pricetarget'];
// Get live quote from API
$currentPrice = $buyPrice; // Default fallback
$priceSource = 'cached';
$dayChangePct = null;
$marketState = null; // "PRE", "REGULAR", "POST", "POSTPOST", "CLOSED"
$high52 = null;
$low52 = null;
if (isset($liveData[$symbol])) {
$q = $liveData[$symbol];
$currentPrice = $q['price'];
$priceSource = 'live';
$marketState = $q['market_state'];
$high52 = $q['high_52'];
$low52 = $q['low_52'];
if (!empty($q['previous_close']) && $q['previous_close'] > 0) {
$dayChangePct = (($currentPrice - $q['previous_close']) / $q['previous_close']) * 100;
}
}
// Calculate metrics
$yieldPct = $buyPrice > 0 ? (($currentPrice - $buyPrice) / $buyPrice) * 100 : 0;
$distanceToTarget = $priceTarget > 0 ? ($currentPrice / $priceTarget) * 100 : 0;
$distanceToTarget = max(0, min(100, $distanceToTarget));
// Is the target realistic based on the stock's own 52-week range?
// A target above the 52-week high hasn't been reached in a year -
// worth flagging so the ETA doesn't look more confident than it should.
$targetAboveHigh52 = ($high52 !== null && $priceTarget > $high52);
// Days held
$daysHeld = '—';
$daysHeldNum = 0;
if (isset($stock['date'])) {
try {
$bought = new DateTime($stock['date']);
$now = new DateTime();
$diff = $bought->diff($now);
$daysHeldNum = $diff->days;
$daysHeld = $daysHeldNum . 'd';
} catch (Exception $e) {
$daysHeld = '—';
}
}
// ---- ETA TO TARGET ----
// Estimate based on the average daily price change since purchase,
// projected forward at that same rate until the target is reached.
// This is a rough linear estimate, not a forecast - stocks don't move linearly.
$etaLabel = '—';
$etaClass = 'neutral';
if ($distanceToTarget >= 100) {
$etaLabel = 'Target reached';
$etaClass = 'reached';
} elseif ($daysHeldNum > 0) {
$dailyRate = ($currentPrice - $buyPrice) / $daysHeldNum;
if ($dailyRate > 0) {
$remaining = $priceTarget - $currentPrice;
$daysNeeded = $remaining / $dailyRate;
if ($daysNeeded <= 0) {
$etaLabel = 'Target reached';
$etaClass = 'reached';
} elseif ($daysNeeded > 3650) {
// Rate is positive but so slow it's not a meaningful estimate
$etaLabel = 'Trend too slow to estimate';
$etaClass = 'stale';
} else {
$etaDate = new DateTime();
$etaDate->modify('+' . (int)ceil($daysNeeded) . ' days');
$daysNeededRounded = (int)ceil($daysNeeded);
if ($daysNeededRounded <= 60) {
$etaLabel = '~' . $daysNeededRounded . 'd (' . $etaDate->format('d M') . ')';
} else {
$months = round($daysNeededRounded / 30, 1);
$etaLabel = '~' . $months . 'mo (' . $etaDate->format('d M Y') . ')';
}
$etaClass = 'positive';
}
} else {
// Flat or declining since purchase - no positive trend to project forward
$etaLabel = 'No upward trend';
$etaClass = 'negative';
}
} else {
// Bought today - not enough history for a rate
$etaLabel = 'Too early to estimate';
$etaClass = 'stale';
}
// Flag when the target sits above the 52-week high - the ETA above
// assumes the recent trend continues, but the stock hasn't actually
// traded at the target price in the last year, so treat the estimate
// with extra skepticism in that case.
if ($targetAboveHigh52 && $distanceToTarget < 100 && $etaClass === 'positive') {
$etaLabel .= ' ⚠️ above 52w high';
}
// Turn Yahoo's raw market_state into a friendly label/class
$marketStateLabel = null;
$marketStateClass = 'closed';
switch ($marketState) {
case 'REGULAR':
$marketStateLabel = '🟢 open';
$marketStateClass = 'open';
break;
case 'PRE':
$marketStateLabel = '🟡 pre-market';
$marketStateClass = 'pre';
break;
case 'POST':
case 'POSTPOST':
$marketStateLabel = '🟡 after-hours';
$marketStateClass = 'post';
break;
case 'CLOSED':
$marketStateLabel = '🔴 closed';
$marketStateClass = 'closed';
break;
default:
$marketStateLabel = null; // unknown state - don't show a badge
}
$processedStocks[] = [
'symbol' => $symbol,
'depot' => $stock['depot'] ?? '—',
'bought_date' => $stock['date'] ?? 'N/A',
'days_held' => $daysHeld,
'buy_price' => number_format($buyPrice, 2),
'current_price' => number_format($currentPrice, 2),
'price_target' => number_format($priceTarget, 2),
'yield_pct' => number_format($yieldPct, 1),
'yield_class' => $yieldPct >= 0 ? 'green' : 'red',
'yield_sign' => $yieldPct >= 0 ? '+' : '',
'distance_pct' => number_format($distanceToTarget, 0),
'progress_class' => $distanceToTarget >= 100 ? 'over' : ($distanceToTarget >= 75 ? 'near' : ''),
'currency' => $stock['currency'] ?? '$',
'price_source' => $priceSource,
'eta_label' => $etaLabel,
'eta_class' => $etaClass,
'day_change_pct' => $dayChangePct !== null ? number_format($dayChangePct, 1) : null,
'day_change_class' => ($dayChangePct !== null && $dayChangePct >= 0) ? 'green' : 'red',
'day_change_sign' => ($dayChangePct !== null && $dayChangePct >= 0) ? '+' : '',
'market_state_label' => $marketStateLabel,
'market_state_class' => $marketStateClass,
'high_52' => $high52 !== null ? number_format($high52, 2) : null,
'low_52' => $low52 !== null ? number_format($low52, 2) : null
];
}
// Sort by closest to target
usort($processedStocks, function($a, $b) {
$aReached = (float)$a['distance_pct'] >= 100;
$bReached = (float)$b['distance_pct'] >= 100;
if ($aReached && !$bReached) return -1;
if (!$aReached && $bReached) return 1;
return (float)$b['distance_pct'] - (float)$a['distance_pct'];
});
$count = count($processedStocks);
$lastUpdate = date('H:i:s');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Targets Widget</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 10px;
}
.widget {
max-width: 80%;
width: 100%;
max-height: 450px;
background: white;
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
overflow: hidden;
display: flex;
flex-direction: column;
}
.widget-header {
padding: 12px 18px 8px 18px;
border-bottom: 1px solid #eef2f7;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
gap: 5px;
}
.widget-header h2 {
font-size: 0.85rem;
font-weight: 700;
color: #1a1a2e;
letter-spacing: 0.3px;
}
.widget-header .count {
font-size: 0.7rem;
background: #4a6cf7;
color: white;
padding: 2px 10px;
border-radius: 12px;
font-weight: 600;
}
.widget-header .last-update {
font-size: 0.6rem;
color: #9ca3af;
}
.widget-header .api-status {
font-size: 0.6rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
.api-status.live {
background: #dcfce7;
color: #16a34a;
}
.api-status.cached {
background: #fef3c7;
color: #d97706;
}
.scroll-area {
overflow-y: auto;
padding: 8px 12px 12px 12px;
flex: 1;
}
.scroll-area::-webkit-scrollbar { width: 4px; }
.scroll-area::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
.scroll-area::-webkit-scrollbar-track { background: transparent; }
.stock-item {
background: #f8fafc;
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 8px;
border-left: 3px solid #4a6cf7;
transition: background 0.15s;
}
.stock-item:hover { background: #f1f4f9; }
.stock-item.reached {
border-left-color: #22c55e;
background: #f0fdf4;
}
.stock-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
}
.stock-symbol {
font-weight: 700;
font-size: 0.85rem;
color: #1a1a2e;
}
.stock-symbol .depot {
font-weight: 400;
font-size: 0.6rem;
color: #6b7280;
background: #e5e7eb;
padding: 1px 8px;
border-radius: 10px;
margin-left: 5px;
}
.badge-source {
font-size: 0.55rem;
padding: 1px 8px;
border-radius: 10px;
margin-left: 5px;
font-weight: 600;
}
.badge-source.live {
background: #dcfce7;
color: #16a34a;
}
.badge-source.cached {
background: #fef3c7;
color: #d97706;
}
.badge-market {
font-size: 0.55rem;
padding: 1px 8px;
border-radius: 10px;
margin-left: 5px;
font-weight: 600;
}
.badge-market.open {
background: #dcfce7;
color: #16a34a;
}
.badge-market.pre,
.badge-market.post {
background: #fef3c7;
color: #d97706;
}
.badge-market.closed {
background: #f1f5f9;
color: #64748b;
}
.badge-target {
font-size: 0.55rem;
background: #22c55e;
color: white;
padding: 1px 8px;
border-radius: 10px;
margin-left: 5px;
font-weight: 600;
}
.stock-metrics {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 0.7rem;
}
.metric {
display: flex;
align-items: center;
gap: 2px;
}
.metric .label {
color: #9ca3af;
font-size: 0.6rem;
}
.metric .val {
font-weight: 600;
color: #1a1a2e;
}
.metric .val.green { color: #22c55e; }
.metric .val.red { color: #ef4444; }
.progress-mini {
margin-top: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.progress-track {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 10px;
background: linear-gradient(90deg, #4a6cf7, #6d8cff);
transition: width 0.5s ease;
}
.progress-fill.over { background: linear-gradient(90deg, #22c55e, #16a34a); }
.progress-fill.near { background: linear-gradient(90deg, #f59e0b, #d97706); }
.progress-text {
font-size: 0.65rem;
font-weight: 600;
color: #4b5563;
white-space: nowrap;
min-width: 45px;
text-align: right;
}
.progress-text .target {
color: #9ca3af;
font-weight: 400;
}
.no-stocks {
padding: 30px 20px;
text-align: center;
color: #9ca3af;
font-size: 0.85rem;
}
.eta-row {
margin-top: 6px;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.65rem;
}
.eta-row .eta-label-tag {
color: #9ca3af;
}
.eta-row .eta-value {
font-weight: 600;
}
.eta-value.positive { color: #4a6cf7; }
.eta-value.reached { color: #22c55e; }
.eta-value.negative { color: #ef4444; }
.eta-value.stale, .eta-value.neutral { color: #9ca3af; }
@media (max-width: 480px) {
.widget { max-width: 95%; }
.stock-row { flex-direction: column; align-items: stretch; }
.stock-metrics { justify-content: space-between; }
}
</style>
</head>
<body>
<div class="widget">
<div class="widget-header">
<h2>🎯 Price Targets</h2>
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<span class="api-status <?= $apiWorking ? 'live' : 'cached' ?>">
<?= $apiWorking ? '📡 Yahoo Finance' : '📊 Cached' ?>
</span>
<span class="count"><?= $count ?></span>
<span class="last-update">updated <?= $lastUpdate ?></span>
</div>
</div>
<div class="scroll-area">
<?php if (empty($processedStocks)): ?>
<div class="no-stocks">No stocks with price targets found</div>
<?php else: ?>
<?php foreach ($processedStocks as $stock):
$reached = (float)$stock['distance_pct'] >= 100;
$isLive = $stock['price_source'] === 'live';
?>
<div class="stock-item <?= $reached ? 'reached' : '' ?>">
<div class="stock-row">
<div class="stock-symbol">
<?= htmlspecialchars($stock['symbol']) ?>
<span class="depot"><?= htmlspecialchars($stock['depot']) ?></span>
<span class="badge-source <?= $isLive ? 'live' : 'cached' ?>">
<?= $isLive ? '📡 live' : '📊 cached' ?>
</span>
<?php if ($stock['market_state_label'] !== null): ?>
<span class="badge-market <?= $stock['market_state_class'] ?>">
<?= $stock['market_state_label'] ?>
</span>
<?php endif; ?>
<?php if ($reached): ?>
<span class="badge-target">✅ TARGET</span>
<?php endif; ?>
</div>
<div class="stock-metrics">
<span class="metric">
<span class="label">Bought</span>
<span class="val"><?= htmlspecialchars($stock['bought_date']) ?></span>
</span>
<span class="metric">
<span class="label">Held</span>
<span class="val"><?= $stock['days_held'] ?></span>
</span>
<span class="metric">
<span class="label">Buy</span>
<span class="val"><?= $stock['currency'] ?><?= $stock['buy_price'] ?></span>
</span>
<span class="metric">
<span class="label">Current</span>
<span class="val">
<?= $stock['currency'] ?><?= $stock['current_price'] ?>
</span>
</span>
<span class="metric">
<span class="label">Yield</span>
<span class="val <?= $stock['yield_class'] ?>"><?= $stock['yield_sign'] ?><?= $stock['yield_pct'] ?>%</span>
</span>
<?php if ($stock['day_change_pct'] !== null): ?>
<span class="metric">
<span class="label">Today</span>
<span class="val <?= $stock['day_change_class'] ?>"><?= $stock['day_change_sign'] ?><?= $stock['day_change_pct'] ?>%</span>
</span>
<?php endif; ?>
</div>
</div>
<div class="progress-mini">
<div class="progress-track">
<div class="progress-fill <?= $stock['progress_class'] ?>" style="width: <?= $stock['distance_pct'] ?>%;"></div>
</div>
<span class="progress-text">
<?= $stock['distance_pct'] ?>%
<span class="target">/ <?= $stock['currency'] ?><?= $stock['price_target'] ?></span>
</span>
</div>
<div class="eta-row">
<span class="eta-label-tag">ETA to target:</span>
<span class="eta-value <?= $stock['eta_class'] ?>"><?= htmlspecialchars($stock['eta_label']) ?></span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</body>
</html>