summaryrefslogtreecommitdiff
path: root/web/template.html
diff options
context:
space:
mode:
Diffstat (limited to 'web/template.html')
-rw-r--r--web/template.html269
1 files changed, 269 insertions, 0 deletions
diff --git a/web/template.html b/web/template.html
new file mode 100644
index 0000000..5349e38
--- /dev/null
+++ b/web/template.html
@@ -0,0 +1,269 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Stock RSI / KD</title>
+<style>
+ :root {
+ --bg: #ffffff; --fg: #1d2330; --muted: #7a8294; --grid: #e6e9ef; --panel: #f6f7fa;
+ --up: #d6323c; --down: #1f9d55; --line1: #2563eb; --line2: #e08a00; --accent: #6d4aff;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root { --bg: #14171f; --fg: #e4e7ee; --muted: #8a92a6; --grid: #262b38; --panel: #1b1f2a;
+ --up: #ff5d66; --down: #34c77b; --line1: #5b8cff; --line2: #ffb038; --accent: #9a82ff; }
+ }
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
+ font: 13px/1.4 system-ui, sans-serif; }
+ body { display: flex; flex-direction: column; }
+ header { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; padding: 8px 16px;
+ border-bottom: 1px solid var(--grid); }
+ header h1 { font-size: 15px; margin: 0; }
+ #info { flex: 1 1 100%; font-variant-numeric: tabular-nums; min-height: 1.4em; }
+ #info b { font-weight: 600; }
+ label, button { color: var(--muted); }
+ button { background: var(--panel); border: 1px solid var(--grid); border-radius: 4px; padding: 2px 8px; cursor: pointer; }
+ button:hover { color: var(--fg); }
+ #wrap { flex: 1; min-height: 0; position: relative; }
+ svg { width: 100%; height: 100%; display: block; touch-action: none; user-select: none; cursor: crosshair; }
+ svg text { fill: var(--muted); font-size: 11px; }
+ svg text.title { fill: var(--fg); font-weight: 600; }
+ .grid { stroke: var(--grid); stroke-width: 1; }
+ .guide { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 4 3; }
+</style>
+</head>
+<body>
+<header>
+ <h1 id="title"></h1>
+ <label>Colours <select id="scheme"><option value="tw">Red up / green down (TW)</option>
+ <option value="us">Green up / red down (US)</option></select></label>
+ <button id="reset">Reset zoom</button>
+ <button id="clear">Clear pins</button>
+ <span style="color:var(--muted)">wheel: zoom · drag: pan · click: pin a day (shows change vs pin) · triangles: K/D crosses · dashed lines: regular divergence (reversal) · dotted lines: hidden divergence (continuation)</span>
+ <div id="info"></div>
+</header>
+<div id="wrap"><svg id="svg"></svg></div>
+<script>
+const D = /*__DATA__*/null;
+const N = D.candles.length;
+const $ = id => document.getElementById(id);
+const svg = $('svg');
+const M = { l: 12, r: 52, t: 6, b: 20 };
+const MIN_SPAN = Math.min(10, N);
+
+let start = Math.max(0, N - 120), end = N; // visible window, in bar indices (float)
+let hover = null, pins = [];
+let scheme = 'tw';
+try { scheme = localStorage.getItem('scheme') || 'tw'; } catch (e) {}
+$('scheme').value = scheme;
+$('title').textContent = `${D.name} · RSI(${D.rsiPeriod}) KD(${D.kdPeriod}) · ${D.candles[0].date} – ${D.candles[N-1].date}`;
+
+const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
+const fmt = (v, d = 2) => v == null ? '–' : v.toFixed(d);
+const fmtVol = v => v >= 1e8 ? (v/1e8).toFixed(2)+'億' : v >= 1e4 ? (v/1e4).toFixed(1)+'萬' : String(v);
+function colors() {
+ const up = css('--up'), down = css('--down');
+ return scheme === 'tw' ? { up, down } : { up: down, down: up };
+}
+
+let W = 0, H = 0, plotW = 0, panels = [];
+function layout() {
+ W = svg.clientWidth; H = svg.clientHeight;
+ plotW = W - M.l - M.r;
+ const h = H - M.t - M.b, gap = 14;
+ const hp = Math.round((h - 2 * gap) * 0.5), hs = Math.round((h - 2 * gap) * 0.25);
+ panels = [
+ { id: 'price', y: M.t, h: hp },
+ { id: 'rsi', y: M.t + hp + gap, h: hs },
+ { id: 'kd', y: M.t + hp + hs + 2 * gap, h: h - hp - hs - 2 * gap },
+ ];
+}
+const xOf = i => M.l + (i - start + 0.5) * plotW / (end - start);
+const idxAt = px => Math.floor(start + (px - M.l) / plotW * (end - start));
+const clampWin = () => {
+ let span = Math.min(Math.max(end - start, MIN_SPAN), N);
+ start = Math.min(Math.max(start, 0), N - span); end = start + span;
+};
+
+function niceTicks(lo, hi, n) {
+ const step0 = (hi - lo) / n, p = Math.pow(10, Math.floor(Math.log10(step0)));
+ const step = [1, 2, 5, 10].map(m => m * p).find(s => s >= step0);
+ const out = [];
+ for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) out.push(v);
+ return out;
+}
+
+function render() {
+ layout();
+ if (W <= 0) return;
+ const lo = Math.max(0, Math.floor(start)), hi = Math.min(N - 1, Math.ceil(end));
+ const bw = plotW / (end - start);
+ const col = colors();
+ let s = '';
+ const [P, R, K] = panels;
+
+ // ---- price panel scale
+ let pmin = Infinity, pmax = -Infinity, vmax = 0;
+ for (let i = lo; i <= hi; i++) {
+ const c = D.candles[i];
+ pmin = Math.min(pmin, c.Low); pmax = Math.max(pmax, c.High); vmax = Math.max(vmax, c.Volume);
+ }
+ const pad = (pmax - pmin || 1) * 0.06; pmin -= pad; pmax += pad;
+ const volH = P.h * 0.16;
+ const py = v => P.y + (pmax - v) / (pmax - pmin) * (P.h - volH - 4);
+ const ind = (p, v) => p.y + (100 - v) / 100 * p.h;
+
+ // ---- grids / axes
+ for (const t of niceTicks(pmin, pmax, 5)) {
+ s += `<line class="grid" x1="${M.l}" x2="${W-M.r}" y1="${py(t)}" y2="${py(t)}"/>` +
+ `<text x="${W-M.r+4}" y="${py(t)+4}">${+t.toFixed(2)}</text>`;
+ }
+ for (const [p, name, lines] of [[R, `RSI(${D.rsiPeriod})`, [30, 70]], [K, `KD(${D.kdPeriod})`, [20, 80]]]) {
+ s += `<rect x="${M.l}" y="${p.y}" width="${plotW}" height="${p.h}" fill="${css('--panel')}" opacity=".5"/>`;
+ for (const t of [0, 50, 100]) s += `<line class="grid" x1="${M.l}" x2="${W-M.r}" y1="${ind(p,t)}" y2="${ind(p,t)}"/>`;
+ for (const t of lines) s += `<line x1="${M.l}" x2="${W-M.r}" y1="${ind(p,t)}" y2="${ind(p,t)}" stroke="${css('--muted')}" stroke-dasharray="3 3"/>` +
+ `<text x="${W-M.r+4}" y="${ind(p,t)+4}">${t}</text>`;
+ s += `<text class="title" x="${M.l+4}" y="${p.y+12}">${name}</text>`;
+ }
+ // x ticks
+ const target = Math.max(1, Math.floor(plotW / 90)), every = Math.max(1, Math.round((end - start) / target));
+ for (let i = lo; i <= hi; i++) {
+ if (i % every) continue;
+ const x = xOf(i);
+ s += `<line class="grid" x1="${x}" x2="${x}" y1="${P.y}" y2="${K.y+K.h}"/>` +
+ `<text x="${x}" y="${K.y+K.h+14}" text-anchor="middle">${D.candles[i].date.slice(every > 30 ? 0 : 2)}</text>`;
+ }
+
+ // ---- candles + volume
+ const bodyW = Math.max(1, bw * 0.7);
+ for (let i = lo; i <= hi; i++) {
+ const c = D.candles[i], x = xOf(i);
+ const prev = i > 0 ? D.candles[i-1].Close : c.Open;
+ const colr = c.Close > c.Open || (c.Close === c.Open && c.Close >= prev) ? col.up : col.down;
+ if (bw >= 3) {
+ const top = py(Math.max(c.Open, c.Close)), bot = py(Math.min(c.Open, c.Close));
+ s += `<line x1="${x}" x2="${x}" y1="${py(c.High)}" y2="${py(c.Low)}" stroke="${colr}"/>` +
+ `<rect x="${x-bodyW/2}" y="${top}" width="${bodyW}" height="${Math.max(1, bot-top)}" fill="${colr}"/>`;
+ } else {
+ s += `<line x1="${x}" x2="${x}" y1="${py(c.High)}" y2="${py(c.Low)}" stroke="${colr}"/>`;
+ }
+ const vh = vmax ? c.Volume / vmax * volH : 0;
+ s += `<rect x="${x-bodyW/2}" y="${P.y+P.h-vh}" width="${bodyW}" height="${vh}" fill="${colr}" opacity=".35"/>`;
+ }
+
+ // ---- indicator lines (break on nulls)
+ const poly = (arr, p, color) => {
+ let d = '', pen = false;
+ for (let i = lo; i <= hi; i++) {
+ const v = arr[i];
+ if (v == null) { pen = false; continue; }
+ d += `${pen ? 'L' : 'M'}${xOf(i).toFixed(1)},${ind(p, v).toFixed(1)}`; pen = true;
+ }
+ return `<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/>`;
+ };
+ s += poly(D.rsi, R, css('--accent')) + poly(D.k, K, css('--line1')) + poly(D.d, K, css('--line2'));
+ s += `<text x="${M.l+70}" y="${K.y+12}" style="fill:${css('--line1')}">K</text>` +
+ `<text x="${M.l+84}" y="${K.y+12}" style="fill:${css('--line2')}">D</text>`;
+
+ // ---- signals
+ for (const c of D.crosses) {
+ if (c.index < lo || c.index > hi) continue;
+ const x = xOf(c.index), y = ind(K, D.k[c.index]), g = c.kind === 'golden';
+ const sz = c.strong ? 6 : 4;
+ s += `<path d="M${x},${y + (g ? -2 : 2)} l${-sz},${g ? -sz*1.6 : sz*1.6} h${2*sz}z" fill="${g ? css('--line1') : css('--line2')}" opacity="${c.strong ? 1 : .55}"/>`;
+ }
+ for (const dv of D.divergences) {
+ if (dv.i2 < lo || dv.i1 > hi) continue;
+ const bull = dv.kind === 'bullish', color = bull ? css('--line1') : css('--line2');
+ const arr = dv.indicator === 'RSI' ? D.rsi : D.k, p = dv.indicator === 'RSI' ? R : K;
+ // regular (reversal) = dashed, hidden (continuation) = dotted
+ const dash = dv.class === 'hidden'
+ ? `stroke="${color}" stroke-width="2" stroke-dasharray="1 4" stroke-linecap="round"`
+ : `stroke="${color}" stroke-width="1.5" stroke-dasharray="5 3"`;
+ s += `<line x1="${xOf(dv.i1)}" y1="${py(dv.p1)}" x2="${xOf(dv.i2)}" y2="${py(dv.p2)}" ${dash} opacity=".7"/>` +
+ `<line x1="${xOf(dv.j1)}" y1="${ind(p, arr[dv.j1])}" x2="${xOf(dv.j2)}" y2="${ind(p, arr[dv.j2])}" ${dash}/>`;
+ }
+
+ // ---- pins and crosshair
+ const guide = (i, cls, extra = '') => {
+ const x = xOf(i);
+ return `<line class="${cls}" x1="${x}" x2="${x}" y1="${P.y}" y2="${K.y+K.h}" ${extra}/>`;
+ };
+ for (const i of pins) if (i >= lo && i <= hi) s += guide(i, 'guide', `stroke="${css('--accent')}" stroke-dasharray="none"`);
+ if (hover != null) {
+ s += guide(hover, 'guide');
+ const c = D.candles[hover];
+ s += `<line class="guide" x1="${M.l}" x2="${W-M.r}" y1="${py(c.Close)}" y2="${py(c.Close)}"/>` +
+ `<text x="${W-M.r+4}" y="${py(c.Close)+4}" style="fill:${css('--fg')};font-weight:600">${fmt(c.Close)}</text>`;
+ }
+ svg.innerHTML = s;
+ updateInfo();
+}
+
+function updateInfo() {
+ const i = hover != null ? hover : N - 1, c = D.candles[i], col = colors();
+ const prev = i > 0 ? D.candles[i-1].Close : null;
+ const chg = prev ? (c.Close - prev) / prev * 100 : null;
+ const cc = chg == null ? '' : `style="color:${chg >= 0 ? col.up : col.down}"`;
+ let t = `<b>${c.date}</b> &nbsp; O ${fmt(c.Open)} &nbsp; H ${fmt(c.High)} &nbsp; L ${fmt(c.Low)} &nbsp; ` +
+ `C <b ${cc}>${fmt(c.Close)}</b> <span ${cc}>(${chg == null ? '–' : (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%'})</span> &nbsp; ` +
+ `Vol ${fmtVol(c.Volume)} &nbsp; <span style="color:${css('--accent')}">RSI ${fmt(D.rsi[i], 1)}</span> &nbsp; ` +
+ `<span style="color:${css('--line1')}">K ${fmt(D.k[i], 1)}</span> &nbsp; <span style="color:${css('--line2')}">D ${fmt(D.d[i], 1)}</span>`;
+ if (pins.length) {
+ const p = pins[pins.length - 1], pc = D.candles[p];
+ const diff = c.Close - pc.Close, pct = diff / pc.Close * 100;
+ const days = Math.abs(i - p);
+ t += ` &nbsp;|&nbsp; vs pin ${pc.date}: <b style="color:${diff >= 0 ? col.up : col.down}">${diff >= 0 ? '+' : ''}${diff.toFixed(2)} (${diff >= 0 ? '+' : ''}${pct.toFixed(2)}%)</b>, ${days} bars`;
+ }
+ $('info').innerHTML = t;
+}
+
+// ---- interaction
+function svgX(e) { return e.clientX - svg.getBoundingClientRect().left; }
+svg.addEventListener('wheel', e => {
+ e.preventDefault();
+ const span = end - start;
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { // horizontal scroll pans
+ const shift = e.deltaX / plotW * span; start += shift; end += shift;
+ } else { // wheel / pinch zooms around cursor
+ const frac = Math.min(1, Math.max(0, (svgX(e) - M.l) / plotW));
+ const anchor = start + frac * span;
+ const ns = Math.min(Math.max(span * Math.exp(e.deltaY * (e.ctrlKey ? 0.01 : 0.002)), MIN_SPAN), N);
+ start = anchor - frac * ns; end = start + ns;
+ }
+ clampWin(); render();
+}, { passive: false });
+
+let drag = null;
+svg.addEventListener('pointerdown', e => { drag = { x: svgX(e), start, moved: false }; svg.setPointerCapture(e.pointerId); });
+svg.addEventListener('pointermove', e => {
+ const x = svgX(e);
+ if (drag) {
+ const dx = x - drag.x;
+ if (Math.abs(dx) > 3) drag.moved = true;
+ if (drag.moved) {
+ const span = end - start; start = drag.start - dx / plotW * span; end = start + span; clampWin();
+ }
+ }
+ const i = idxAt(x);
+ hover = i >= 0 && i < N ? i : null;
+ render();
+});
+svg.addEventListener('pointerup', e => {
+ if (drag && !drag.moved && hover != null) {
+ const k = pins.indexOf(hover);
+ if (k >= 0) pins.splice(k, 1); else pins.push(hover);
+ render();
+ }
+ drag = null;
+});
+svg.addEventListener('pointerleave', () => { if (!drag) { hover = null; render(); } });
+$('reset').onclick = () => { start = 0; end = N; render(); };
+$('clear').onclick = () => { pins = []; render(); };
+$('scheme').onchange = e => { scheme = e.target.value; try { localStorage.setItem('scheme', scheme); } catch (_) {} render(); };
+new ResizeObserver(render).observe($('wrap'));
+matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
+clampWin(); render();
+</script>
+</body>
+</html>