added rally-cycles
This commit is contained in:
@@ -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">← Monthly Returns</a>
|
||||
<a href="cumulative.html">← 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>
|
||||
Reference in New Issue
Block a user