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>
+15 -14
View File
@@ -44,9 +44,12 @@ h2 { font-size: 0.95rem; color: #aaa; margin-bottom: 16px; text-align: center; f
</head>
<body>
<a href="index.html" class="back-link">&larr; Back to Monthly Returns</a>
<div class="nav-links">
<a href="index.html">&larr; Monthly Returns</a>
<a href="alltime-lows.html">Gains from ATL &rarr;</a>
</div>
<h1>Cumulative Returns & Drawdown Cycles</h1>
<p class="subtitle">Growth of $1 invested, with drawdown from all-time highs</p>
<p class="subtitle">Actual index values and drawdown from all-time highs</p>
<div class="controls">
<div class="control-group">
@@ -61,7 +64,7 @@ h2 { font-size: 0.95rem; color: #aaa; margin-bottom: 16px; text-align: center; f
</div>
<div class="chart-container">
<h2>Cumulative Return (Growth of $1)</h2>
<h2>Index Value Over Time</h2>
<div class="chart-wrapper">
<canvas id="cumulativeChart"></canvas>
</div>
@@ -101,32 +104,30 @@ function buildSeries(indexName) {
if (!raw) return { cumulative: [], drawdown: [], cycles: [] };
const sorted = [...raw].sort((a, b) => a[0] * 100 + a[1] - (b[0] * 100 + b[1]));
let cum = 1;
let ath = 1;
let ath = sorted[0][2];
let athDate = null;
const cumulative = [];
const drawdownSeries = [];
const cycles = [];
let currentCycle = null;
for (const [y, m, ret] of sorted) {
cum *= (1 + ret / 100);
for (const [y, m, price] of sorted) {
const date = new Date(y, m - 1, 1);
cumulative.push({ x: date, y: cum, year: y });
cumulative.push({ x: date, y: price, year: y });
if (cum > ath) {
if (price > ath) {
if (athDate && currentCycle) {
currentCycle.recoveryDate = date;
currentCycle.recoveryDuration = (y - currentCycle.troughYear) * 12 + (m - currentCycle.troughMonth);
cycles.push(currentCycle);
currentCycle = null;
}
ath = cum;
ath = price;
athDate = date;
}
const dd = ((cum - ath) / ath) * 100;
const dd = ((price - ath) / ath) * 100;
drawdownSeries.push({ x: date, y: dd, year: y });
if (dd < 0 && !currentCycle && athDate) {
@@ -138,9 +139,9 @@ function buildSeries(indexName) {
};
}
if (currentCycle && dd < ((currentCycle.troughValue || Infinity) - currentCycle.peakValue) / currentCycle.peakValue * 100) {
if (currentCycle && price < (currentCycle.troughValue || Infinity)) {
currentCycle.troughDate = date;
currentCycle.troughValue = cum;
currentCycle.troughValue = price;
currentCycle.troughYear = y;
currentCycle.troughMonth = m;
}
@@ -174,7 +175,7 @@ function updateCharts() {
type: 'line',
data: {
datasets: [{
label: c.label + ' (Growth of $1)',
label: c.label,
data: cumulative.map(p => ({ x: p.x, y: p.y })),
borderColor: c.border,
backgroundColor: c.bg,
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -45,6 +45,11 @@ h1 { text-align: center; margin-bottom: 8px; font-size: 1.6rem; color: #fff; }
<h1>Monthly Market Returns by Index</h1>
<p class="subtitle">S&P 500, NASDAQ, Dow Jones, Russell 2000</p>
<div style="display: flex; gap: 16px; justify-content: center; margin-bottom: 16px;">
<a href="cumulative.html" style="color: #5b8def; text-decoration: none; font-size: 0.82rem;">Drawdowns from ATH &rarr;</a>
<a href="alltime-lows.html" style="color: #7ed321; text-decoration: none; font-size: 0.82rem;">Gains from ATL &rarr;</a>
</div>
<div class="controls">
<div class="control-group">
<label>From:</label>
@@ -74,12 +79,19 @@ h1 { text-align: center; margin-bottom: 8px; font-size: 1.6rem; color: #fff; }
<script src="data/marketdata.js"></script>
<script>
const DATA = {};
const CLOSES = {};
for (const idx of Object.keys(RAW)) {
DATA[idx] = RAW[idx].map(([y, m, r]) => ({
const sorted = [...RAW[idx]].sort((a, b) => a[0] * 100 + a[1] - (b[0] * 100 + b[1]));
CLOSES[idx] = sorted.map(([y, m, price]) => ({
x: new Date(y, m - 1, 1),
y: r,
price,
year: y
}));
DATA[idx] = sorted.map(([y, m, price], i) => {
const prev = i > 0 ? sorted[i - 1][2] : price;
const monthlyReturn = ((price - prev) / prev) * 100;
return { x: new Date(y, m - 1, 1), y: monthlyReturn, year: y };
});
}
const COLORS = {
+304
View File
@@ -0,0 +1,304 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Market Returns - Rally Cycles (Trough to Peak)</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: 320px; }
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">&larr; Drawdowns from ATH</a>
</div>
<h1>Rally Cycles: Trough to Peak</h1>
<p class="subtitle">Index values during rally phases only (decline phases removed)</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 - Rally Phases Only</h2>
<div class="chart-wrapper">
<canvas id="rallyChart"></canvas>
</div>
</div>
<div class="chart-container">
<h2>Normalized Rally Comparison (% from trough)</h2>
<div class="chart-wrapper small">
<canvas id="normalizedChart"></canvas>
</div>
</div>
<div class="chart-container">
<h2>All Rally Cycles</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>Duration</th><th>Status</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' }
};
const CYCLE_COLORS = ['#5b8def','#f5a623','#7ed321','#e74c8b','#9b59b6','#1abc9c','#e67e22','#3498db','#e74c3c','#2ecc71'];
function buildCycles(indexName) {
const raw = RAW[indexName];
if (!raw) return [];
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 cycles = [];
let currentCycle = null;
for (const [y, m, price] of sorted) {
const date = new Date(y, m - 1, 1);
if (price < atl) {
if (atlDate && currentCycle) {
currentCycle.ended = true;
cycles.push(currentCycle);
currentCycle = null;
}
atl = price;
atlDate = date;
}
if (!currentCycle && atlDate) {
const gain = ((price - atl) / atl) * 100;
if (gain > 0) {
currentCycle = {
troughDate: atlDate,
troughValue: atl,
troughYear: atlDate.getFullYear(),
troughMonth: atlDate.getMonth() + 1,
peakValue: price,
peakYear: y,
peakMonth: m,
points: [{ x: date, y: price, monthsFromTrough: 0, pctFromTrough: 0 }],
ended: false
};
}
}
if (currentCycle && price > currentCycle.peakValue) {
currentCycle.peakValue = price;
currentCycle.peakYear = y;
currentCycle.peakMonth = m;
}
if (currentCycle) {
const monthsIn = (y - currentCycle.troughYear) * 12 + (m - currentCycle.troughMonth);
const pctGain = ((price - currentCycle.troughValue) / currentCycle.troughValue) * 100;
currentCycle.points.push({ x: date, y: price, monthsFromTrough: monthsIn, pctFromTrough: pctGain });
}
}
if (currentCycle) {
currentCycle.ended = false;
cycles.push(currentCycle);
}
return cycles
.map(c => ({
...c,
gainPct: ((c.peakValue - c.troughValue) / c.troughValue) * 100,
durationMonths: (c.peakYear - c.troughYear) * 12 + (c.peakMonth - c.troughMonth)
}))
.filter(c => c.gainPct > 5 && c.points.length > 3);
}
let rallyChart = null;
let normChart = null;
function updateCharts() {
const idx = document.getElementById('indexSelect').value;
const c = COLORS[idx];
const cycles = buildCycles(idx);
const rallyData = [];
for (const cyc of cycles) {
rallyData.push({ x: cyc.points[0].x, y: null });
for (const pt of cyc.points) {
rallyData.push({ x: pt.x, y: pt.y });
}
rallyData.push({ x: cyc.points[cyc.points.length - 1].x, y: null });
}
if (rallyChart) rallyChart.destroy();
const ctx1 = document.getElementById('rallyChart').getContext('2d');
rallyChart = new Chart(ctx1, {
type: 'line',
data: {
datasets: [{
label: c.label + ' (rally phases)',
data: rallyData,
borderColor: c.border,
backgroundColor: c.bg,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.3,
fill: true,
spanGaps: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: {
legend: { labels: { color: '#ccc', usePointStyle: true, pointStyle: 'line' } },
tooltip: {
backgroundColor: '#1a1d27',
titleColor: '#fff',
bodyColor: '#ccc',
borderColor: '#333',
borderWidth: 1,
filter: item => item.parsed.y !== null,
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) }
}
}
}
});
const normDatasets = cycles.map((cyc, i) => ({
label: cyc.troughDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' }) + ' (' + cyc.gainPct.toFixed(0) + '%)',
data: cyc.points.map(pt => ({ x: pt.monthsFromTrough, y: pt.pctFromTrough })),
borderColor: CYCLE_COLORS[i % CYCLE_COLORS.length],
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 3,
tension: 0.3,
fill: false
}));
if (normChart) normChart.destroy();
const ctx2 = document.getElementById('normalizedChart').getContext('2d');
normChart = new Chart(ctx2, {
type: 'line',
data: { datasets: normDatasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
labels: { color: '#ccc', usePointStyle: true, pointStyle: 'line', boxWidth: 12, padding: 12, font: { size: 11 } }
},
tooltip: {
backgroundColor: '#1a1d27',
titleColor: '#fff',
bodyColor: '#ccc',
borderColor: '#333',
borderWidth: 1,
callbacks: {
title: ctx => `Month ${ctx[0].parsed.x}`,
label: ctx => `${ctx.dataset.label}: +${ctx.parsed.y.toFixed(1)}%`
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#888', callback: v => 'Mo ' + v },
title: { display: true, text: 'Months from trough', color: '#888' }
},
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 peakStr = cyc.ended
? cyc.peakDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
: '<span class="pos">Ongoing</span>';
const status = cyc.ended ? '<span class="pos">Completed</span>' : '<span class="neg">In progress</span>';
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.durationMonths} months</td>
<td>${status}</td>
</tr>`;
}
}
document.getElementById('indexSelect').addEventListener('change', updateCharts);
updateCharts();
</script>
</body>
</html>