635 lines
23 KiB
PHP
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>
|