⚠️ stocks.json not found'); } $json = file_get_contents($jsonFile); $data = json_decode($json, true); if (!is_array($data)) { die('
⚠️ Invalid JSON format
'); } // 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'); ?> Stock Targets Widget

🎯 Price Targets

updated
No stocks with price targets found
= 100; $isLive = $stock['price_source'] === 'live'; ?>
✅ TARGET
Bought Held Buy Current Yield % Today %
% /
ETA to target: