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

589 lines
21 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 YOUR ROBINHOOD API ----
// Returns an array of everything useful for display + estimates, or null on failure.
function fetchQuoteData($symbol) {
// cURL needs an ABSOLUTE URL - a relative path can't be resolved by cURL.
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/');
$url = "$scheme://$host$base/robinhood_api.php?symbol=" . urlencode($symbol);
$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);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$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;
}
if (!$data || !isset($data['success']) || $data['success'] !== true) {
error_log("[stocks_widget] fetchQuoteData($symbol) API returned success=false or malformed payload: " . substr($response, 0, 300));
return null;
}
$quote = $data['quote'] ?? [];
$fundamentals = $data['fundamentals'] ?? [];
$marketHours = $data['market_hours'] ?? [];
$price = null;
if (isset($quote['extended_hours_price']) && $quote['extended_hours_price'] > 0) {
$price = $quote['extended_hours_price'];
} elseif (isset($quote['last_price']) && $quote['last_price'] > 0) {
$price = $quote['last_price'];
}
if ($price === null) {
error_log("[stocks_widget] fetchQuoteData($symbol) no usable price field in quote: " . substr($response, 0, 300));
return null;
}
return [
'price' => $price,
'previous_close' => $quote['previous_close'] ?? null,
'is_open' => $marketHours['is_open'] ?? null,
'high_52' => $fundamentals['high_52_weeks'] ?? null,
'low_52' => $fundamentals['low_52_weeks'] ?? 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;
$isMarketOpen = null;
$high52 = null;
$low52 = null;
if (isset($liveData[$symbol])) {
$q = $liveData[$symbol];
$currentPrice = $q['price'];
$priceSource = 'live';
$isMarketOpen = $q['is_open'];
$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';
}
$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) ? '+' : '',
'is_market_open' => $isMarketOpen,
'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.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 ? '📡 Robinhood' : '📊 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['is_market_open'] !== null): ?>
<span class="badge-market <?= $stock['is_market_open'] ? 'open' : 'closed' ?>">
<?= $stock['is_market_open'] ? '🟢 open' : '🔴 closed' ?>
</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>