added rally-cycles

This commit is contained in:
2026-05-15 19:09:35 -04:00
parent e234bec1fc
commit 9c16217554
5 changed files with 627 additions and 17 deletions
+293
View File
@@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Market Returns - Gain from All-Time Lows</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e0e0e0; padding: 24px; }
h1 { text-align: center; margin-bottom: 4px; font-size: 1.6rem; color: #fff; }
.subtitle { text-align: center; color: #888; margin-bottom: 24px; font-size: 0.85rem; }
.chart-container { background: #1a1d27; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
.chart-wrapper { position: relative; height: 440px; }
.chart-wrapper.small { height: 340px; }
h2 { font-size: 0.95rem; color: #aaa; margin-bottom: 16px; text-align: center; font-weight: 500; letter-spacing: 0.5px; text-transform: uppercase; }
.controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; justify-content: center; margin-bottom: 20px; }
.control-group { display: flex; align-items: center; gap: 8px; }
.control-group label { font-size: 0.8rem; color: #aaa; }
.control-group select { padding: 6px 10px; background: #1a1d27; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.85rem; }
.control-group select:focus { outline: none; border-color: #7ed321; }
.cycle-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.cycle-table th { padding: 8px 10px; text-align: left; color: #888; font-weight: 500; border-bottom: 1px solid #2a2d37; white-space: nowrap; }
.cycle-table td { padding: 6px 10px; border-bottom: 1px solid #1f222c; white-space: nowrap; }
.cycle-table tr:hover td { background: rgba(255,255,255,0.02); }
.pos { color: #7ed321; }
.neg { color: #e74c3c; }
.nav-links { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
.nav-links a { color: #5b8def; text-decoration: none; font-size: 0.82rem; }
.nav-links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="nav-links">
<a href="index.html">&larr; Monthly Returns</a>
<a href="cumulative.html">Drawdowns from ATH &rarr;</a>
</div>
<h1>Gains from All-Time Lows</h1>
<p class="subtitle">Actual index values and upside gain from all-time lows</p>
<div class="controls">
<div class="control-group">
<label>Index:</label>
<select id="indexSelect">
<option value="S&P_500">S&P 500</option>
<option value="NASDAQ">NASDAQ</option>
<option value="DOW_JONES">Dow Jones</option>
<option value="RUSSELL_2000">Russell 2000</option>
</select>
</div>
</div>
<div class="chart-container">
<h2>Index Value Over Time</h2>
<div class="chart-wrapper">
<canvas id="cumulativeChart"></canvas>
</div>
</div>
<div class="chart-container">
<h2>Gain from All-Time Low (%)</h2>
<div class="chart-wrapper small">
<canvas id="gainChart"></canvas>
</div>
</div>
<div class="chart-container">
<h2>Cycles: Trough-to-Peak Rallies</h2>
<div style="overflow-x:auto">
<table class="cycle-table" id="cycleTable">
<thead><tr>
<th>Trough Date</th><th>Trough Value</th><th>Peak Date</th><th>Peak Value</th>
<th>Gain</th><th>Rally Duration</th><th>Next Low Date</th><th>Decline Duration</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="data/marketdata.js"></script>
<script>
const COLORS = {
'S&P_500': { border: '#5b8def', bg: 'rgba(91,141,239,0.12)', label: 'S&P 500' },
'NASDAQ': { border: '#f5a623', bg: 'rgba(245,166,35,0.12)', label: 'NASDAQ' },
'DOW_JONES': { border: '#7ed321', bg: 'rgba(126,211,33,0.12)', label: 'Dow Jones' },
'RUSSELL_2000': { border: '#e74c8b', bg: 'rgba(231,76,139,0.12)', label: 'Russell 2000' }
};
function buildSeries(indexName) {
const raw = RAW[indexName];
if (!raw) return { cumulative: [], gain: [], cycles: [] };
const sorted = [...raw].sort((a, b) => a[0] * 100 + a[1] - (b[0] * 100 + b[1]));
let atl = sorted[0][2];
let atlDate = null;
const cumulative = [];
const gainSeries = [];
const cycles = [];
let currentCycle = null;
for (const [y, m, price] of sorted) {
const date = new Date(y, m - 1, 1);
cumulative.push({ x: date, y: price, year: y });
const gain = ((price - atl) / atl) * 100;
gainSeries.push({ x: date, y: gain, year: y });
if (price < atl) {
if (atlDate && currentCycle) {
currentCycle.nextLowDate = date;
currentCycle.nextLowValue = price;
currentCycle.declineDuration = (y - currentCycle.peakYear) * 12 + (m - currentCycle.peakMonth);
cycles.push(currentCycle);
currentCycle = null;
}
atl = price;
atlDate = date;
}
if (gain > 0 && !currentCycle && atlDate) {
currentCycle = {
troughDate: atlDate,
troughValue: atl,
troughYear: atlDate.getFullYear(),
troughMonth: atlDate.getMonth() + 1,
peakValue: atl,
peakYear: y,
peakMonth: m
};
}
if (currentCycle && price > currentCycle.peakValue) {
currentCycle.peakValue = price;
currentCycle.peakYear = y;
currentCycle.peakMonth = m;
}
}
if (currentCycle) {
currentCycle.nextLowDate = null;
currentCycle.nextLowValue = null;
currentCycle.declineDuration = null;
cycles.push(currentCycle);
}
const enrichedCycles = cycles.map(c => ({
...c,
gainPct: ((c.peakValue - c.troughValue) / c.troughValue) * 100,
rallyDuration: (c.peakYear - c.troughYear) * 12 + (c.peakMonth - c.troughMonth)
})).filter(c => c.gainPct > 3);
return { cumulative, gain: gainSeries, cycles: enrichedCycles };
}
let cumChart = null;
let gainChart = null;
function updateCharts() {
const idx = document.getElementById('indexSelect').value;
const c = COLORS[idx];
const { cumulative, gain, cycles } = buildSeries(idx);
if (cumChart) cumChart.destroy();
const ctx1 = document.getElementById('cumulativeChart').getContext('2d');
cumChart = new Chart(ctx1, {
type: 'line',
data: {
datasets: [{
label: c.label,
data: cumulative.map(p => ({ x: p.x, y: p.y })),
borderColor: c.border,
backgroundColor: c.bg,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#ccc', usePointStyle: true, pointStyle: 'line' } },
tooltip: {
backgroundColor: '#1a1d27',
titleColor: '#fff',
bodyColor: '#ccc',
borderColor: '#333',
borderWidth: 1,
callbacks: {
label: ctx => `$${ctx.parsed.y.toFixed(2)}`
}
}
},
scales: {
x: {
type: 'time',
time: { unit: 'year', displayFormats: { year: 'yyyy' } },
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#888', maxTicksLimit: 20 }
},
y: {
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#888', callback: v => '$' + v.toFixed(0) }
}
}
}
});
if (gainChart) gainChart.destroy();
const ctx2 = document.getElementById('gainChart').getContext('2d');
gainChart = new Chart(ctx2, {
type: 'line',
data: {
datasets: [{
label: c.label + ' Gain from ATL',
data: gain.map(p => ({ x: p.x, y: p.y })),
borderColor: '#7ed321',
backgroundColor: 'rgba(126,211,33,0.12)',
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#ccc', usePointStyle: true, pointStyle: 'line' } },
tooltip: {
backgroundColor: '#1a1d27',
titleColor: '#fff',
bodyColor: '#ccc',
borderColor: '#333',
borderWidth: 1,
callbacks: {
label: ctx => `+${ctx.parsed.y.toFixed(2)}%`
}
}
},
scales: {
x: {
type: 'time',
time: { unit: 'year', displayFormats: { year: 'yyyy' } },
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#888', maxTicksLimit: 20 }
},
y: {
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#888', callback: v => '+' + v.toFixed(0) + '%' },
min: 0
}
}
}
});
const tbody = document.querySelector('#cycleTable tbody');
tbody.innerHTML = '';
for (const cyc of cycles) {
const troughStr = cyc.troughDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
const peakDate = new Date(cyc.peakYear, cyc.peakMonth - 1, 1);
const peakStr = peakDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
const nextLowStr = cyc.nextLowDate
? cyc.nextLowDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
: '<span class="pos">Ongoing</span>';
const declineStr = cyc.declineDuration != null ? cyc.declineDuration + ' months' : '—';
tbody.innerHTML += `<tr>
<td>${troughStr}</td>
<td>$${cyc.troughValue.toFixed(2)}</td>
<td>${peakStr}</td>
<td>$${cyc.peakValue.toFixed(2)}</td>
<td class="pos">+${cyc.gainPct.toFixed(1)}%</td>
<td>${cyc.rallyDuration} months</td>
<td>${nextLowStr}</td>
<td>${declineStr}</td>
</tr>`;
}
}
document.getElementById('indexSelect').addEventListener('change', updateCharts);
updateCharts();
</script>
</body>
</html>