Files
Stonks/leverage.php
2026-07-09 00:38:32 +02:00

1222 lines
43 KiB
PHP

<?php
/**
* Portfolio tracker
* - Reads stocks.json (same directory as this file)
* - Only considers entries where "nrbght" is set (i.e. shares actually bought)
* - Entry prices in stocks.json are ALWAYS USD
* - Pulls live price from Yahoo Finance, converts to USD if the ticker's
* listing currency isn't USD (e.g. .HK, .DE tickers)
* - Shows P&L, % change, and TWO stop-loss suggestions:
* - SAFE stop: PROTECTS GAINS (locks in a percentage of your profit)
* - SWING stop: wider, based on volatility (lets stock breathe)
* - Includes next-day prediction sparklines (best, worst, median cases)
* - Uses 60 days of historical data for institutional stocks
* - Classifies stocks as: Penny Stock, High-Growth Volatile, Institutional Keeper, Recovery Play, etc.
* - Gives clear, actionable advice in plain language
* - Sorted by gain % (highest to lowest)
*
* Usage: put this file + stocks.json in the same folder, run via PHP built-in
* server (php -S localhost:8000) or any PHP-enabled webserver, then open
* portfolio.php in a browser.
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
// ---------- Config ----------
$jsonFile = __DIR__ . '/stocks.json';
$userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
$minTrimProfitUsd = 10.0;
$historyDays = 60;
// ---------- Helpers ----------
function fetchYahooQuote(string $ticker, string $userAgent, bool $withHistory = false, int $historyDays = 60): ?array {
if ($withHistory) {
$range = '3mo';
} else {
$range = '5d';
}
$url = "https://query1.finance.yahoo.com/v8/finance/chart/" . rawurlencode($ticker)
. "?range={$range}&interval=1d";
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_USERAGENT => $userAgent,
]);
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$raw) {
return null;
}
$data = json_decode($raw, true);
$result = $data['chart']['result'][0] ?? null;
if (!$result) {
return null;
}
$meta = $result['meta'] ?? [];
$price = $meta['regularMarketPrice'] ?? null;
if ($price === null) {
$price = $result['indicators']['quote'][0]['close'][0] ?? null;
}
if ($price === null) {
return null;
}
$prevClose = $meta['chartPreviousClose'] ?? $meta['previousClose'] ?? null;
$history = [];
if ($withHistory) {
$timestamps = $result['timestamp'] ?? [];
$quote = $result['indicators']['quote'][0] ?? [];
$highs = $quote['high'] ?? [];
$lows = $quote['low'] ?? [];
$closes = $quote['close'] ?? [];
foreach ($timestamps as $i => $ts) {
if (!isset($closes[$i], $highs[$i], $lows[$i]) || $closes[$i] === null) {
continue;
}
$history[] = [
'date' => date('Y-m-d', $ts),
'high' => (float) $highs[$i],
'low' => (float) $lows[$i],
'close' => (float) $closes[$i],
];
}
$history = array_slice($history, -$historyDays);
}
return [
'price' => (float) $price,
'prevClose' => $prevClose !== null ? (float) $prevClose : null,
'currency' => strtoupper($meta['currency'] ?? 'USD'),
'history' => $history,
];
}
function getFxRateToUsd(string $currency, string $userAgent, array &$fxCache): ?float {
$currency = strtoupper($currency);
if ($currency === 'USD') {
return 1.0;
}
if (isset($fxCache[$currency])) {
return $fxCache[$currency];
}
$quote = fetchYahooQuote($currency . 'USD=X', $userAgent);
$rate = $quote['price'] ?? null;
$fxCache[$currency] = $rate;
return $rate;
}
function computeRecentStats(array $history): ?array {
if (count($history) < 3) {
return null;
}
$lows = array_column($history, 'low');
$highs = array_column($history, 'high');
$closes = array_column($history, 'close');
$rangePcts = [];
$dailyChanges = [];
foreach ($history as $i => $bar) {
if ($bar['close'] > 0) {
$rangePcts[] = (($bar['high'] - $bar['low']) / $bar['close']) * 100;
}
if ($i > 0 && $history[$i-1]['close'] > 0) {
$dailyChanges[] = (($bar['close'] - $history[$i-1]['close']) / $history[$i-1]['close']) * 100;
}
}
return [
'days' => count($history),
'low' => min($lows),
'high' => max($highs),
'avgClose' => array_sum($closes) / count($closes),
'avgRangePct' => count($rangePcts) ? array_sum($rangePcts) / count($rangePcts) : 0.0,
'dailyChanges' => $dailyChanges,
'lastClose' => end($closes),
];
}
/**
* Classify a stock based on price, volatility, and performance
*/
function classifyStock(float $price, float $avgDailyMove, float $stdDev, float $gainPct, float $avgRangePct): array {
$classification = [];
$emoji = '';
$quip = '';
$color = '';
// Determine price category
if ($price < 5) {
$priceCategory = 'penny';
$priceLabel = 'Penny Stock';
$priceEmoji = '🪙';
} elseif ($price < 20) {
$priceCategory = 'low';
$priceLabel = 'Low-Priced';
$priceEmoji = '💵';
} elseif ($price < 50) {
$priceCategory = 'mid';
$priceLabel = 'Mid-Cap';
$priceEmoji = '📊';
} else {
$priceCategory = 'high';
$priceLabel = 'Institutional';
$priceEmoji = '🏛️';
}
// Determine volatility category
if ($avgDailyMove > 10 || $stdDev > 8) {
$volCategory = 'high';
$volLabel = 'Very Volatile';
$volEmoji = '⚡';
} elseif ($avgDailyMove > 5 || $stdDev > 4) {
$volCategory = 'medium';
$volLabel = 'Moderate Volatility';
$volEmoji = '📈';
} else {
$volCategory = 'low';
$volLabel = 'Low Volatility';
$volEmoji = '🐢';
}
// Determine performance category
if ($gainPct > 50) {
$perfCategory = 'star';
$perfLabel = 'Star Performer';
$perfEmoji = '⭐';
} elseif ($gainPct > 20) {
$perfCategory = 'gainer';
$perfLabel = 'Solid Gainer';
$perfEmoji = '📈';
} elseif ($gainPct > 5) {
$perfCategory = 'steady';
$perfLabel = 'Steady Growth';
$perfEmoji = '🌱';
} elseif ($gainPct > -5) {
$perfCategory = 'flat';
$perfLabel = 'Flat';
$perfEmoji = '➡️';
} elseif ($gainPct > -20) {
$perfCategory = 'dip';
$perfLabel = 'In a Dip';
$perfEmoji = '📉';
} else {
$perfCategory = 'crash';
$perfLabel = 'Heavy Loss';
$perfEmoji = '🔥';
}
// Combine for final classification
if ($priceCategory == 'penny' && $volCategory == 'high') {
$classification['type'] = 'penny_volatile';
$classification['label'] = 'High-Risk Penny Stock';
$emoji = '🎰';
$quip = 'High risk, high reward — trade with caution';
$color = '#f44336';
} elseif ($priceCategory == 'penny') {
$classification['type'] = 'penny';
$classification['label'] = 'Penny Stock';
$emoji = '🪙';
$quip = 'Cheap but unpredictable — manage risk tightly';
$color = '#ff9800';
} elseif ($priceCategory == 'high' && $volCategory == 'low') {
$classification['type'] = 'keeper';
$classification['label'] = 'Institutional Keeper';
$emoji = '🏛️';
$quip = 'Solid, stable, hold for the long term';
$color = '#4caf50';
} elseif ($priceCategory == 'high' && $volCategory == 'medium') {
$classification['type'] = 'growth';
$classification['label'] = 'Quality Growth';
$emoji = '🚀';
$quip = 'Growing with institutional backing — hold through volatility';
$color = '#8bc34a';
} elseif ($volCategory == 'high' && $perfCategory == 'star') {
$classification['type'] = 'rocket';
$classification['label'] = 'Rocket Ship 🚀';
$emoji = '🚀';
$quip = 'Massive gains but extremely volatile — lock in profits';
$color = '#ff6f00';
} elseif ($volCategory == 'high' && $perfCategory == 'crash') {
$classification['type'] = 'risky';
$classification['label'] = 'High-Risk Recovery';
$emoji = '🎲';
$quip = 'Selling off hard — only for high-risk tolerance';
$color = '#d32f2f';
} elseif ($volCategory == 'medium' && $perfCategory == 'gainer') {
$classification['type'] = 'momentum';
$classification['label'] = 'Momentum Play';
$emoji = '⚡';
$quip = 'Good momentum with moderate risk — let it ride with a stop';
$color = '#ffa726';
} elseif ($volCategory == 'low' && $perfCategory == 'steady') {
$classification['type'] = 'dividend';
$classification['label'] = 'Steady Eddi';
$emoji = '🐢';
$quip = 'Slow and steady — set it and forget it';
$color = '#66bb6a';
} elseif ($perfCategory == 'dip' && $volCategory == 'high') {
$classification['type'] = 'bounce';
$classification['label'] = 'Bounce Candidate';
$emoji = '🏀';
$quip = 'High volatility dip — could bounce back strongly';
$color = '#ff9800';
} elseif ($perfCategory == 'dip') {
$classification['type'] = 'value';
$classification['label'] = 'Value Play';
$emoji = '💎';
$quip = 'Temporarily down but fundamentally sound — consider averaging down';
$color = '#4caf50';
} else {
$classification['type'] = 'mixed';
$classification['label'] = 'Mixed Signals';
$emoji = '🤔';
$quip = 'No clear pattern — wait for confirmation';
$color = '#9e9e9e';
}
$classification['emoji'] = $emoji;
$classification['quip'] = $quip;
$classification['color'] = $color;
$classification['priceLabel'] = $priceLabel;
$classification['volLabel'] = $volLabel;
$classification['perfLabel'] = $perfLabel;
return $classification;
}
function generatePredictions(float $currentPrice, array $history, float $fxRate): array {
$dataPoints = count($history);
if ($dataPoints < 3) {
return [
'best' => $currentPrice * 1.05,
'worst' => $currentPrice * 0.95,
'median' => $currentPrice,
'bestPct' => 5.0,
'worstPct' => -5.0,
'medianPct' => 0.0,
'confidence' => 'low',
'confidenceScore' => 20,
'trend' => 'flat',
'reasoning' => 'Not enough historical data (need at least 3 days) for reliable predictions.',
'dataPoints' => $dataPoints,
'isInstitutional' => false
];
}
$changes = [];
for ($i = 1; $i < count($history); $i++) {
if ($history[$i-1]['close'] > 0) {
$changes[] = (($history[$i]['close'] - $history[$i-1]['close']) / $history[$i-1]['close']) * 100;
}
}
if (count($changes) < 2) {
return [
'best' => $currentPrice * 1.05,
'worst' => $currentPrice * 0.95,
'median' => $currentPrice,
'bestPct' => 5.0,
'worstPct' => -5.0,
'medianPct' => 0.0,
'confidence' => 'low',
'confidenceScore' => 15,
'trend' => 'flat',
'reasoning' => 'Only ' . count($changes) . ' daily changes available.',
'dataPoints' => $dataPoints,
'isInstitutional' => false
];
}
$sortedChanges = $changes;
sort($sortedChanges);
$count = count($sortedChanges);
$overallAvg = array_sum($sortedChanges) / $count;
$stdDev = 0;
foreach ($sortedChanges as $change) {
$stdDev += pow($change - $overallAvg, 2);
}
$stdDev = sqrt($stdDev / $count);
$isInstitutional = ($currentPrice > 20 && $stdDev < 8);
$recentDays = $isInstitutional ? 10 : 5;
$recentDays = min($recentDays, $count);
$recentAvg = 0;
for ($i = $count - $recentDays; $i < $count; $i++) {
$recentAvg += $sortedChanges[$i];
}
$recentAvg = $recentAvg / $recentDays;
$trendWeight = $isInstitutional ? 0.6 : 0.7;
$trendFactor = ($recentAvg * $trendWeight) + ($overallAvg * (1 - $trendWeight));
$confidenceScore = 50;
if ($stdDev < 2) {
$confidenceScore += 25;
} elseif ($stdDev < 4) {
$confidenceScore += 15;
} elseif ($stdDev < 8) {
$confidenceScore += 5;
} else {
$confidenceScore -= 10;
}
$trendStrength = abs($trendFactor);
if ($trendStrength > 5) {
$confidenceScore += 15;
} elseif ($trendStrength > 2) {
$confidenceScore += 8;
} else {
$confidenceScore -= 5;
}
$trendDiff = abs($recentAvg - $overallAvg);
if ($trendDiff < 1) {
$confidenceScore += 10;
} elseif ($trendDiff < 3) {
$confidenceScore += 5;
} else {
$confidenceScore -= 10;
}
if ($count >= 40) {
$confidenceScore += 20;
} elseif ($count >= 30) {
$confidenceScore += 15;
} elseif ($count >= 20) {
$confidenceScore += 10;
} elseif ($count >= 10) {
$confidenceScore += 5;
} else {
$confidenceScore -= 5;
}
$range = max($sortedChanges) - min($sortedChanges);
if ($range > 20) {
$confidenceScore -= 15;
} elseif ($range > 15) {
$confidenceScore -= 8;
} elseif ($range > 10) {
$confidenceScore -= 3;
} else {
$confidenceScore += 5;
}
$avgMove = array_sum(array_map('abs', $sortedChanges)) / $count;
if ($avgMove > 10) {
$confidenceScore -= 10;
} elseif ($avgMove > 6) {
$confidenceScore -= 5;
} elseif ($avgMove < 3) {
$confidenceScore += 5;
}
if ($isInstitutional && $stdDev < 6) {
$confidenceScore += 10;
}
$reversals = 0;
for ($i = 1; $i < count($sortedChanges); $i++) {
if (($sortedChanges[$i] > 0 && $sortedChanges[$i-1] < 0) ||
($sortedChanges[$i] < 0 && $sortedChanges[$i-1] > 0)) {
$reversals++;
}
}
$reversalRate = $reversals / (count($sortedChanges) - 1);
if ($reversalRate > 0.5) {
$confidenceScore -= 10;
} elseif ($reversalRate > 0.3) {
$confidenceScore -= 5;
} else {
$confidenceScore += 5;
}
$confidenceScore = max(0, min(100, $confidenceScore));
if ($confidenceScore >= 70) {
$confidence = 'high';
} elseif ($confidenceScore >= 45) {
$confidence = 'medium';
} else {
$confidence = 'low';
}
$maxDailyMovePct = $isInstitutional ? 12.0 : 20.0;
$bestPct = min($maxDailyMovePct, max(0, $sortedChanges[floor($count * 0.85)] + ($trendFactor * 0.15)));
if ($trendFactor > 1) {
$bestPct = max($bestPct, 1.5);
}
$worstPct = max(-$maxDailyMovePct, min(0, $sortedChanges[floor($count * 0.15)] + ($trendFactor * 0.15)));
if ($trendFactor < -1) {
$worstPct = min($worstPct, -1.5);
}
$medianPct = max(-$maxDailyMovePct * 0.5, min($maxDailyMovePct * 0.5, $trendFactor));
$bestPct = max($bestPct, $medianPct + 0.5);
$worstPct = min($worstPct, $medianPct - 0.5);
$trend = 'flat';
if ($trendFactor > 0.5) $trend = 'up';
elseif ($trendFactor < -0.5) $trend = 'down';
$reasoningParts = [];
if ($confidenceScore >= 70) {
$reasoningParts[] = "High confidence (" . round($confidenceScore) . "%)";
} elseif ($confidenceScore >= 45) {
$reasoningParts[] = "Moderate confidence (" . round($confidenceScore) . "%)";
} else {
$reasoningParts[] = "Low confidence (" . round($confidenceScore) . "%)";
}
if ($count >= 40) {
$reasoningParts[] = round($count) . " days of data (excellent)";
} elseif ($count >= 30) {
$reasoningParts[] = round($count) . " days of data (very good)";
} elseif ($count >= 20) {
$reasoningParts[] = round($count) . " days of data (good)";
} elseif ($count >= 10) {
$reasoningParts[] = round($count) . " days of data (decent)";
} else {
$reasoningParts[] = "only " . round($count) . " data points";
}
if ($isInstitutional) {
$reasoningParts[] = "institutional stock with stable pattern";
}
if ($stdDev < 2) {
$reasoningParts[] = "very consistent moves (std dev " . number_format($stdDev, 1) . "%)";
} elseif ($stdDev < 4) {
$reasoningParts[] = "fairly consistent (std dev " . number_format($stdDev, 1) . "%)";
} elseif ($stdDev < 8) {
$reasoningParts[] = "moderate volatility (std dev " . number_format($stdDev, 1) . "%)";
} else {
$reasoningParts[] = "highly volatile (std dev " . number_format($stdDev, 1) . "%)";
}
if ($trendStrength > 5) {
$reasoningParts[] = "strong " . $trend . "ward trend (" . number_format($trendFactor, 1) . "% avg)";
} elseif ($trendStrength > 2) {
$reasoningParts[] = "moderate " . $trend . "ward trend";
} else {
$reasoningParts[] = "no clear trend direction";
}
$lastChange = end($changes);
if (abs($lastChange) > 5) {
$reasoningParts[] = "recent momentum: " . ($lastChange > 0 ? '+' : '') . number_format($lastChange, 1) . "%";
}
if ($reversalRate > 0.5) {
$reasoningParts[] = "many reversals (" . round($reversalRate * 100) . "% of days change direction)";
} elseif ($reversalRate < 0.2) {
$reasoningParts[] = "consistent direction (few reversals)";
}
$reasoning = implode("", $reasoningParts) . ".";
return [
'best' => $currentPrice * (1 + $bestPct / 100),
'worst' => $currentPrice * (1 + $worstPct / 100),
'median' => $currentPrice * (1 + $medianPct / 100),
'bestPct' => $bestPct,
'worstPct' => $worstPct,
'medianPct' => $medianPct,
'confidence' => $confidence,
'confidenceScore' => $confidenceScore,
'trend' => $trend,
'trendStrength' => $trendStrength,
'stdDev' => $stdDev,
'reasoning' => $reasoning,
'avgDailyMove' => $avgMove ?? 0,
'dataPoints' => $count,
'reversalRate' => $reversalRate,
'isInstitutional' => $isInstitutional
];
}
function generateActionableAdvice(array $row, array $predictions, array $classification): string {
$gainPct = $row['gainPct'] ?? 0;
$gainUsd = $row['gainUsd'] ?? 0;
$currentPrice = $row['currentPriceUsd'] ?? 0;
$entry = $row['entry'] ?? 0;
$ticker = $row['ticker'] ?? 'Unknown';
$volatility = $predictions['stdDev'] ?? 0;
$trend = $predictions['trend'] ?? 'flat';
$confScore = $predictions['confidenceScore'] ?? 0;
$dataPoints = $predictions['dataPoints'] ?? 0;
$avgMove = $predictions['avgDailyMove'] ?? 0;
$advice = [];
// Start with classification emoji and type
$advice[] = $classification['emoji'] . " " . $classification['label'] . ": " . $classification['quip'];
// Current situation
if ($gainPct > 0) {
$advice[] = "📈 " . number_format($gainPct, 0) . "% gain (+\$" . number_format($gainUsd, 2) . "/share) from \$" . number_format($entry, 2);
} else {
$advice[] = "📉 " . number_format(abs($gainPct), 0) . "% loss (-\$" . number_format(abs($gainUsd), 2) . "/share) from \$" . number_format($entry, 2);
}
// Volatility assessment
if ($avgMove > 10) {
$advice[] = "⚡ Very volatile — expect large swings";
} elseif ($avgMove > 5) {
$advice[] = "📊 Moderately volatile";
} else {
$advice[] = "🐢 Low volatility — stable";
}
// Trend assessment
if ($trend == 'up' && $gainPct > 0) {
$advice[] = "📈 Strong momentum — trend is your friend";
} elseif ($trend == 'up' && $gainPct < 0) {
$advice[] = "🔄 Recovering — upward trend forming";
} elseif ($trend == 'down' && $gainPct > 0) {
$advice[] = "⚠️ Potential reversal — uptrend losing steam";
} elseif ($trend == 'down' && $gainPct < 0) {
$advice[] = "📉 Downward trend — protect capital";
} else {
$advice[] = "➡️ No clear trend — range-bound";
}
// Prediction
$bestPct = $predictions['bestPct'] ?? 0;
$worstPct = $predictions['worstPct'] ?? 0;
$advice[] = "🔮 Tomorrow: -" . number_format(abs($worstPct), 0) . "% to +" . number_format($bestPct, 0) . "% (median: " . number_format($predictions['medianPct'] ?? 0, 1) . "%)";
// Specific action based on classification
$type = $classification['type'] ?? 'mixed';
switch ($type) {
case 'penny_volatile':
case 'rocket':
$advice[] = "🎯 ACTION: Take partial profits now, set tight SAFE stop at \$" . number_format($row['safeStopUsd'] ?? 0, 2);
break;
case 'keeper':
case 'dividend':
$advice[] = "🎯 ACTION: Hold long-term — use wide SWING stop at \$" . number_format($row['swingStopUsd'] ?? 0, 2) . " to avoid getting shaken out";
break;
case 'growth':
case 'momentum':
$advice[] = "🎯 ACTION: Let it ride with SWING stop at \$" . number_format($row['swingStopUsd'] ?? 0, 2) . " — or SAFE at \$" . number_format($row['safeStopUsd'] ?? 0, 2) . " to protect gains";
break;
case 'bounce':
case 'value':
$advice[] = "🎯 ACTION: Consider averaging down — use SWING stop at \$" . number_format($row['swingStopUsd'] ?? 0, 2) . " for protection";
break;
case 'risky':
$advice[] = "🎯 ACTION: High risk — cut losses or use tight SAFE stop at \$" . number_format($row['safeStopUsd'] ?? 0, 2);
break;
default:
$advice[] = "🎯 ACTION: Use SAFE stop at \$" . number_format($row['safeStopUsd'] ?? 0, 2) . " or SWING at \$" . number_format($row['swingStopUsd'] ?? 0, 2);
}
// Confidence context
if ($confScore >= 70) {
$advice[] = "" . round($confScore) . "% confidence — " . $dataPoints . " days of consistent data";
} elseif ($confScore >= 45) {
$advice[] = "📈 " . round($confScore) . "% confidence — " . $dataPoints . " days shows patterns";
} else {
$advice[] = "⚠️ " . round($confScore) . "% confidence — highly unpredictable";
}
return implode(" | ", $advice);
}
function buildAdviceWithTwoStops(float $shares, float $entry, float $currentUsd, float $fxRate,
?float $dayChangePct, ?array $stats, float $minTrimProfitUsd): array {
$gainPct = $entry > 0 ? (($currentUsd - $entry) / $entry) * 100 : 0.0;
$gainUsd = $currentUsd - $entry;
if ($stats === null) {
$swingStop = $currentUsd * 0.85;
$safeStop = $entry + ($gainUsd * 0.5);
if ($gainPct <= 0) {
$safeStop = $currentUsd * 0.90;
}
$note = 'Not enough recent trading history — using generic levels.';
return [
'action' => "Hold all " . rtrim(rtrim(number_format($shares, 2), '0'), '.') . " shares",
'swingStopUsd' => $swingStop,
'safeStopUsd' => $safeStop,
'swingStopPct' => 15.0,
'safeStopPct' => (($currentUsd - $safeStop) / $currentUsd) * 100,
'sharesTrim' => 0,
'sharesHold' => $shares,
'note' => $note,
];
}
$lowUsd = $stats['low'] * $fxRate;
$avgRangePct = $stats['avgRangePct'];
$days = $stats['days'];
if ($avgRangePct < 2) {
$volLabel = 'low';
$swingBufferPct = 8;
} elseif ($avgRangePct <= 5) {
$volLabel = 'moderate';
$swingBufferPct = 12;
} else {
$volLabel = 'high';
$swingBufferPct = 18;
}
$swingStopUsd = $currentUsd * (1 - $swingBufferPct / 100);
$swingStopUsd = max($swingStopUsd, $lowUsd * 0.95);
$swingStopPct = (($currentUsd - $swingStopUsd) / $currentUsd) * 100;
if ($gainPct >= 100) {
$profitProtectPct = 0.70;
} elseif ($gainPct >= 50) {
$profitProtectPct = 0.60;
} elseif ($gainPct >= 20) {
$profitProtectPct = 0.50;
} elseif ($gainPct >= 10) {
$profitProtectPct = 0.40;
} elseif ($gainPct > 0) {
$profitProtectPct = 0.30;
} else {
$safeStopUsd = $swingStopUsd;
$safeStopPct = $swingStopPct;
$profitProtectPct = 0;
}
if ($gainPct > 0) {
$safeStopUsd = $entry + ($gainUsd * $profitProtectPct);
$supportBasedStop = $lowUsd * 0.98;
if ($supportBasedStop > $safeStopUsd && $supportBasedStop > $entry) {
$safeStopUsd = $supportBasedStop;
}
$safeStopUsd = max($safeStopUsd, $entry);
$safeStopUsd = min($safeStopUsd, $currentUsd * 0.995);
$protectedGainPct = (($safeStopUsd - $entry) / $gainUsd) * 100;
if ($gainUsd > 0) {
$protectedGainPct = min(100, max(0, $protectedGainPct));
} else {
$protectedGainPct = 0;
}
} else {
$safeStopUsd = $swingStopUsd;
$protectedGainPct = 0;
}
$safeStopPct = (($currentUsd - $safeStopUsd) / $currentUsd) * 100;
if ($gainPct >= 100) {
$protectFrac = 0.5;
} elseif ($gainPct >= 50) {
$protectFrac = 0.35;
} elseif ($gainPct >= 20) {
$protectFrac = 0.2;
} else {
$protectFrac = 0.0;
}
$sharesTrim = $protectFrac > 0 ? (float) round($shares * $protectFrac) : 0.0;
$sharesHold = $shares - $sharesTrim;
$fmt = fn($n) => rtrim(rtrim(number_format($n, 2), '0'), '.');
$tooSmallToTrim = false;
if ($sharesTrim > 0) {
$profitCheck = $sharesTrim * ($currentUsd - $entry);
if ($profitCheck < $minTrimProfitUsd) {
$tooSmallToTrim = true;
$sharesTrim = 0.0;
$sharesHold = $shares;
}
}
$gainDesc = "";
if ($gainPct > 0 && $gainUsd > 0) {
$gainDesc = " 🔒 " . number_format($protectedGainPct, 0) . "% of $" . number_format($gainUsd, 2) . " gain protected";
}
if ($sharesTrim > 0) {
$proceedsUsd = $sharesTrim * $currentUsd;
$profitUsd = $sharesTrim * ($currentUsd - $entry);
$action = "SELL {$fmt($sharesTrim)} of {$fmt($shares)} shares now "
. "(~$" . number_format($proceedsUsd, 0) . " proceeds, ~$" . number_format($profitUsd, 0) . " profit); "
. "hold remaining {$fmt($sharesHold)} with SAFE stop at $" . number_format($safeStopUsd, 2)
. " (" . number_format($safeStopPct, 1) . "% below current, " . number_format($protectedGainPct, 0) . "% of gain protected)"
. " or SWING stop at $" . number_format($swingStopUsd, 2)
. " (" . number_format($swingStopPct, 1) . "% below)";
} else {
$action = "Hold all {$fmt($shares)} shares. SAFE stop: $" . number_format($safeStopUsd, 2)
. " (" . number_format($safeStopPct, 1) . "% below current)" . $gainDesc
. " — SWING stop: $" . number_format($swingStopUsd, 2)
. " (" . number_format($swingStopPct, 1) . "% below) to let it breathe through volatility";
}
$note = "Last {$days}d: support $" . number_format($lowUsd, 2)
. ", avg range " . number_format($avgRangePct, 1) . "%/day ({$volLabel} volatility).";
if ($gainPct > 0) {
$note .= " SAFE stop locks in " . number_format($protectedGainPct, 0) . "% of your $" . number_format($gainUsd, 2) . " profit.";
}
$note .= " SWING stop is wider to avoid normal pullbacks.";
if ($tooSmallToTrim) {
$note = "Position too small to justify partial exit — trimming would realize < $"
. number_format($minTrimProfitUsd, 0) . " in profit. " . $note;
}
if ($dayChangePct !== null && $dayChangePct <= -8) {
$note = '⚠️ SHARP DROP TODAY (' . number_format($dayChangePct, 1) . '%). ' . $note;
}
if ($gainPct > 50) {
$note .= " With " . number_format($gainPct, 0) . "% gain, SAFE stop is recommended to lock in profits.";
} elseif ($gainPct > 20) {
$note .= " With " . number_format($gainPct, 0) . "% gain, consider SAFE stop to protect your profits.";
} elseif ($gainPct < 0) {
$note .= " Currently down " . number_format(abs($gainPct), 0) . "% — SWING stop may be safer to avoid whipsaw.";
}
return [
'action' => $action,
'swingStopUsd' => $swingStopUsd,
'safeStopUsd' => $safeStopUsd,
'swingStopPct' => $swingStopPct,
'safeStopPct' => $safeStopPct,
'protectedGainPct' => $protectedGainPct,
'sharesTrim' => $sharesTrim,
'sharesHold' => $sharesHold,
'note' => $note,
];
}
// ---------- Load positions ----------
$positions = [];
$loadError = null;
if (!file_exists($jsonFile)) {
$loadError = "stocks.json not found at {$jsonFile}";
} else {
$raw = file_get_contents($jsonFile);
$decoded = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$loadError = 'stocks.json is not valid JSON: ' . json_last_error_msg();
} else {
$items = $decoded['positions'] ?? $decoded;
foreach ($items as $item) {
if (!isset($item['nrbght'], $item['stock'], $item['price']) || $item['nrbght'] <= 0) {
continue;
}
$positions[] = [
'ticker' => $item['stock'],
'shares' => (float) $item['nrbght'],
'entry' => (float) $item['price'],
'depot' => $item['depot'] ?? '',
'isin' => $item['isin'] ?? '',
'date' => $item['date'] ?? '',
];
}
}
}
// ---------- Fetch quotes + build rows ----------
$rows = [];
$totalCost = 0.0;
$totalValue = 0.0;
$fxCache = [];
foreach ($positions as $pos) {
$quote = fetchYahooQuote($pos['ticker'], $userAgent, true, $historyDays);
$cost = $pos['shares'] * $pos['entry'];
$totalCost += $cost;
if ($quote === null) {
$rows[] = ['currentPriceUsd' => null, 'nativeCurrency' => 'N/A',
'fxRate' => null, 'dayChangePct' => null, 'value' => null,
'gainUsd' => null, 'gainPct' => null, 'action' => '—',
'swingStopUsd' => null, 'safeStopUsd' => null,
'swingStopPct' => null, 'safeStopPct' => null,
'protectedGainPct' => null,
'predictions' => null,
'classification' => null,
'advice' => 'Price fetch failed',
'flag' => 'Price fetch failed'] + $pos;
continue;
}
$nativePrice = $quote['price'];
$nativeCurrency = $quote['currency'];
$fxRate = getFxRateToUsd($nativeCurrency, $userAgent, $fxCache);
if ($fxRate === null) {
$rows[] = ['currentPriceUsd' => null, 'nativeCurrency' => $nativeCurrency,
'fxRate' => null, 'dayChangePct' => null, 'value' => null,
'gainUsd' => null, 'gainPct' => null, 'action' => '—',
'swingStopUsd' => null, 'safeStopUsd' => null,
'swingStopPct' => null, 'safeStopPct' => null,
'protectedGainPct' => null,
'predictions' => null,
'classification' => null,
'advice' => 'FX rate unavailable',
'flag' => "FX rate {$nativeCurrency}->USD unavailable"] + $pos;
continue;
}
$currentUsd = round($nativePrice * $fxRate, 4);
$value = $pos['shares'] * $currentUsd;
$totalValue += $value;
$gainUsd = $value - $cost;
$gainPct = $cost > 0 ? ($gainUsd / $cost) * 100 : 0;
$dayChangePct = null;
if ($quote['prevClose']) {
$dayChangePct = (($nativePrice - $quote['prevClose']) / $quote['prevClose']) * 100;
}
$stats = computeRecentStats($quote['history']);
$advice = buildAdviceWithTwoStops($pos['shares'], $pos['entry'], $currentUsd, $fxRate, $dayChangePct, $stats, $minTrimProfitUsd);
$predictions = generatePredictions($currentUsd, $quote['history'], $fxRate);
// Classify the stock
$avgMove = $predictions['avgDailyMove'] ?? 5;
$stdDev = $predictions['stdDev'] ?? 5;
$avgRangePct = $stats['avgRangePct'] ?? 5;
$classification = classifyStock($currentUsd, $avgMove, $stdDev, $gainPct, $avgRangePct);
// Generate actionable advice
$rowData = [
'ticker' => $pos['ticker'],
'entry' => $pos['entry'],
'currentPriceUsd' => $currentUsd,
'gainPct' => $gainPct,
'gainUsd' => $gainUsd,
'safeStopUsd' => $advice['safeStopUsd'],
'safeStopPct' => $advice['safeStopPct'],
'swingStopUsd' => $advice['swingStopUsd'],
'protectedGainPct' => $advice['protectedGainPct'] ?? 0
];
$actionableAdvice = generateActionableAdvice($rowData, $predictions, $classification);
$rows[] = [
'currentPriceUsd' => $currentUsd,
'nativeCurrency' => $nativeCurrency,
'fxRate' => $fxRate,
'dayChangePct' => $dayChangePct,
'value' => $value,
'gainUsd' => $gainUsd,
'gainPct' => $gainPct,
'action' => $advice['action'],
'swingStopUsd' => $advice['swingStopUsd'],
'safeStopUsd' => $advice['safeStopUsd'],
'swingStopPct' => $advice['swingStopPct'],
'safeStopPct' => $advice['safeStopPct'],
'protectedGainPct'=> $advice['protectedGainPct'] ?? 0,
'predictions' => $predictions,
'classification' => $classification,
'advice' => $actionableAdvice,
'flag' => $advice['note'],
] + $pos;
}
usort($rows, function($a, $b) {
if ($a['gainPct'] === null && $b['gainPct'] === null) return 0;
if ($a['gainPct'] === null) return 1;
if ($b['gainPct'] === null) return -1;
return $b['gainPct'] <=> $a['gainPct'];
});
$totalGainUsd = $totalValue - $totalCost;
$totalGainPct = $totalCost > 0 ? ($totalGainUsd / $totalCost) * 100 : 0;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Portfolio Tracker</title>
<style>
body { font-family: monospace; background: #0e0e0e; color: #ddd; padding: 20px; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; font-size: 10px; }
th, td { border: 1px solid #333; padding: 3px 5px; text-align: right; }
th { background: #1b1b1b; text-align: center; position: sticky; top: 0; }
td.ticker { text-align: left; font-weight: bold; }
td.flag, td.action, td.advice { text-align: left; white-space: normal; max-width: 200px; font-size: 9px; }
td.advice { max-width: 350px; font-size: 9px; color: #aaa; line-height: 1.3; }
td.classification { text-align: center; font-weight: bold; font-size: 10px; }
.pos { color: #4caf50; }
.neg { color: #f44336; }
.summary { margin-top: 20px; font-size: 15px; }
.error { color: #f44336; }
.stop-row { font-size: 8px; color: #888; }
.swing { color: #ffa726; }
.safe { color: #66bb6a; }
.rank { text-align: center; font-weight: bold; color: #888; }
.gain-protect { color: #ffd54f; font-size: 8px; }
.sparkline-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 2px 0;
}
.sparkline-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 7px;
width: 100%;
}
.sparkline-label {
min-width: 10px;
font-weight: bold;
text-align: right;
font-size: 6px;
}
.sparkline-bar {
height: 5px;
border-radius: 1px;
transition: width 0.3s;
min-width: 2px;
}
.sparkline-best { background: #4caf50; }
.sparkline-median { background: #ffa726; }
.sparkline-worst { background: #f44336; }
.sparkline-price {
font-size: 7px;
min-width: 28px;
text-align: right;
color: #aaa;
}
.confidence-indicator {
font-size: 6px;
color: #888;
margin-top: 2px;
text-align: center;
max-width: 100px;
}
.confidence-high { color: #4caf50; }
.confidence-medium { color: #ffa726; }
.confidence-low { color: #f44336; }
.prediction-cell {
min-width: 100px;
padding: 2px 4px;
}
.prediction-reasoning {
font-size: 7px;
color: #666;
max-width: 120px;
text-align: center;
margin-top: 3px;
padding-top: 3px;
border-top: 1px dashed #333;
line-height: 1.2;
}
.confidence-bar {
width: 100%;
height: 2px;
background: #333;
border-radius: 2px;
margin-top: 2px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s;
}
.conf-high { background: #4caf50; }
.conf-medium { background: #ffa726; }
.conf-low { background: #f44336; }
.data-points {
font-size: 6px;
color: #555;
text-align: center;
}
.classification-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 10px;
font-size: 8px;
font-weight: bold;
color: #fff;
}
.classification-quip {
font-size: 7px;
color: #888;
display: block;
margin-top: 2px;
}
@media (max-width: 1200px) {
.sparkline-price { min-width: 18px; font-size: 6px; }
td.flag, td.action { max-width: 120px; }
td.advice { max-width: 180px; font-size: 8px; }
.prediction-reasoning { max-width: 80px; font-size: 6px; }
}
</style>
</head>
<body>
<h2>📈 Portfolio Tracker — Actionable Advice</h2>
<?php if ($loadError): ?>
<p class="error"><?= htmlspecialchars($loadError) ?></p>
<?php else: ?>
<table>
<thead>
<tr>
<th>#</th>
<th>Ticker</th>
<th>Depot</th>
<th>Shares</th>
<th>Entry (USD)</th>
<th>Current (USD)</th>
<th>Gain %</th>
<th>Classification</th>
<th>Next Day Predictions</th>
<th>Best Course of Action</th>
<th>Stops</th>
</tr>
</thead>
<tbody>
<?php
$rank = 1;
foreach ($rows as $r):
$gainPct = $r['gainPct'] ?? null;
$isPositive = $gainPct !== null && $gainPct >= 0;
$protectedPct = $r['protectedGainPct'] ?? 0;
$pred = $r['predictions'] ?? null;
$confScore = $pred['confidenceScore'] ?? 0;
$dataPoints = $pred['dataPoints'] ?? 0;
$advice = $r['advice'] ?? 'No advice available';
$class = $r['classification'] ?? null;
?>
<tr>
<td class="rank"><?= $rank++ ?></td>
<td class="ticker"><?= htmlspecialchars($r['ticker']) ?></td>
<td><?= htmlspecialchars($r['depot']) ?></td>
<td><?= number_format($r['shares'], 2) ?></td>
<td><?= number_format($r['entry'], 2) ?></td>
<td><?= $r['currentPriceUsd'] !== null ? number_format($r['currentPriceUsd'], 2) : '—' ?></td>
<td class="<?= $isPositive ? 'pos' : 'neg' ?>">
<?= $gainPct !== null ? number_format($gainPct, 2) . '%' : '—' ?>
<?php if ($protectedPct > 0 && $gainPct > 0): ?>
<div class="gain-protect">🔒 <?= number_format($protectedPct, 0) ?>%</div>
<?php endif; ?>
</td>
<td class="classification">
<?php if ($class): ?>
<span class="classification-badge" style="background: <?= $class['color'] ?>;">
<?= $class['emoji'] ?> <?= $class['label'] ?>
</span>
<span class="classification-quip"><?= $class['quip'] ?></span>
<?php else: ?>
<span style="color:#666;">—</span>
<?php endif; ?>
</td>
<td class="prediction-cell">
<?php if ($pred !== null && $r['currentPriceUsd'] !== null): ?>
<div class="sparkline-container">
<div class="sparkline-row">
<span class="sparkline-label" style="color:#4caf50;">▲</span>
<div class="sparkline-bar sparkline-best" style="width: <?= min(100, max(0, $pred['bestPct'] * 2 + 20)) ?>%;"></div>
<span class="sparkline-price">$<?= number_format($pred['best'], 2) ?></span>
</div>
<div class="sparkline-row">
<span class="sparkline-label" style="color:#ffa726;">■</span>
<div class="sparkline-bar sparkline-median" style="width: <?= min(100, max(10, $pred['medianPct'] * 2 + 20)) ?>%;"></div>
<span class="sparkline-price">$<?= number_format($pred['median'], 2) ?></span>
</div>
<div class="sparkline-row">
<span class="sparkline-label" style="color:#f44336;">▼</span>
<div class="sparkline-bar sparkline-worst" style="width: <?= min(100, max(0, abs($pred['worstPct']) * 2 + 20)) ?>%;"></div>
<span class="sparkline-price">$<?= number_format($pred['worst'], 2) ?></span>
</div>
<div class="confidence-indicator">
<?php
$confText = ['high' => '📊 High', 'medium' => '📈 Mod', 'low' => '📉 Low'];
$confClass = 'confidence-' . ($pred['confidence'] ?? 'low');
?>
<span class="<?= $confClass ?>">
<?= $confText[$pred['confidence'] ?? 'low'] ?? 'Low' ?> (<?= round($confScore) ?>%)
</span>
<?php if ($pred['trend'] ?? 'flat'): ?>
• <?= $pred['trend'] === 'up' ? '📈' : ($pred['trend'] === 'down' ? '📉' : '➡️') ?>
<?= $pred['trend'] ?>
<?php endif; ?>
</div>
<div class="confidence-bar">
<div class="confidence-fill <?= $confScore >= 70 ? 'conf-high' : ($confScore >= 45 ? 'conf-medium' : 'conf-low') ?>"
style="width: <?= $confScore ?>%;"></div>
</div>
<div class="data-points"><?= $dataPoints ?> days</div>
</div>
<?php else: ?>
<span style="color:#666;">No data</span>
<?php endif; ?>
</td>
<td class="advice"><?= htmlspecialchars($advice) ?></td>
<td class="flag">
<?php if ($r['swingStopUsd'] !== null && $r['safeStopUsd'] !== null): ?>
<div class="stop-row">
<span class="safe">🛡️ SAFE: $<?= number_format($r['safeStopUsd'], 2) ?> (<?= number_format($r['safeStopPct'], 1) ?>%)</span>
<?php if ($protectedPct > 0): ?>
<span class="gain-protect">— <?= number_format($protectedPct, 0) ?>%</span>
<?php endif; ?>
<br>
<span class="swing">🔄 SWING: $<?= number_format($r['swingStopUsd'], 2) ?> (<?= number_format($r['swingStopPct'], 1) ?>%)</span>
</div>
<?php else: ?>
<span style="color:#666;">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="summary">
<strong>Total cost:</strong> $<?= number_format($totalCost, 2) ?> &nbsp;|&nbsp;
<strong>Total value:</strong> $<?= number_format($totalValue, 2) ?> &nbsp;|&nbsp;
<strong>Total gain:</strong> <span class="<?= $totalGainUsd >= 0 ? 'pos' : 'neg' ?>">
$<?= number_format($totalGainUsd, 2) ?> (<?= number_format($totalGainPct, 2) ?>%)
</span>
<br><br>
</div>
<?php endif; ?>
</body>
</html>