22.2 KB
raw
import {
createChart,
CandlestickSeries,
LineSeries,
HistogramSeries,
ColorType,
createSeriesMarkers,
} from "lightweight-charts";
// Paper Ledger theme: ink figures and hairline rules on the warm paper
// surface. autoSize wires an internal ResizeObserver so the chart tracks
// its container on every viewport.
//
// handleScroll / handleScale are OFF: the range buttons are the only way to
// change what's shown, so the viewport can never pan or zoom into empty space
// beyond the loaded data. Drag is freed up for the measure tool below.
// Pane resizing is off for the same reason — the RSI pane keeps a fixed size.
const chartOptions = {
autoSize: true,
handleScroll: false,
handleScale: false,
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: "#6b6456",
fontFamily: "'JetBrains Mono', monospace",
attributionLogo: false,
panes: { enableResize: false, separatorColor: "rgba(33,31,26,0.16)" },
},
grid: {
vertLines: { color: "rgba(33,31,26,0.07)" },
horzLines: { color: "rgba(33,31,26,0.07)" },
},
rightPriceScale: { borderColor: "rgba(33,31,26,0.16)" },
timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 },
crosshair: { mode: 1 },
};
// Overlay indicator inks. The candlesticks own semantic green/red, and the
// rest of the app reserves green/amber/red for good/ok/bad, so the moving
// averages get their own muted, non-semantic palette — wayfinding lines, not
// value judgments — desaturated to sit inside the warm Paper Ledger world.
const OVERLAY_INK = {
sma50: "#3f6f9c",
sma200: "#9c6b3f",
ema21: "#6f5b86",
};
const RSI_INK = "#3f6f9c";
// Phase 28 benchmark overlay: a fourth wayfinding ink, dashed so it reads as
// "what would have happened in the benchmark" rather than blending with the
// SMAs. Anchored to the fund's first visible close, so the two lines start
// together and the divergence is the relative performance the eye should
// follow.
const BENCH_INK = "#7a5237";
// Supertrend is the deliberate green/red exception (a user call): the band's
// whole point is its trend colour, and up=green / down=red matches the app's
// price-move semantics. It reuses the candle green/red exactly so the overlay
// reads as part of the price, not a separate wayfinding ink.
const SUPERTREND_UP = "#2f7d4f";
const SUPERTREND_DOWN = "#b23b32";
const VOLUME_UP = "rgba(47,125,79,0.38)";
const VOLUME_DOWN = "rgba(178,59,50,0.38)";
// Phase 25: earnings-date markers. A small ink dot above each candle that
// matches a past 8-K item-2.02 date. Same warm-paper ink-faint as the rest
// of the Paper Ledger palette so it reads as wayfinding, not a value verdict
// (the candles still own green/red and the indicator inks own the other
// non-semantic palette).
const EARNINGS_INK = "rgba(33,31,26,0.55)";
/** `12.4` -> `+$12.40`, `-3` -> `-$3.00`. */
function fmtMoney(n) {
const sign = n > 0 ? "+" : n < 0 ? "-" : "";
return `${sign}$${Math.abs(n).toFixed(2)}`;
}
// A bar's `time` is a `YYYY-MM-DD` string on the daily ranges and a UNIX-
// seconds number on the intraday ranges (1D / 1W). `barMs` returns epoch-ms
// for either, and `fmtBarTime` a human label — a plain date for daily bars, a
// New-York date+time for intraday ones (so the measure readout reads sensibly
// in both worlds).
function barMs(t) {
return typeof t === "number" ? t * 1000 : Date.parse(t);
}
function fmtBarTime(t) {
if (typeof t !== "number") return t;
return new Date(t * 1000).toLocaleString("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/**
* A human caption for a visible span, e.g. "over 6 months", "over 8 years".
* Derived from the actual bars shown rather than the range button, so a deep
* MAX history clamped to what fits is described honestly. Handles both the
* daily date strings and the intraday UNIX-seconds times, scaling its unit
* from hours (an intraday session) up to years.
*/
function spanLabel(from, to) {
const ms = barMs(to) - barMs(from);
const hours = ms / 3.6e6;
if (hours < 20) {
const h = Math.max(1, Math.round(hours));
return `over ${h} hour${h === 1 ? "" : "s"}`;
}
const days = ms / 8.64e7;
if (days < 11) {
const d = Math.round(days);
return `over ${d} day${d === 1 ? "" : "s"}`;
}
const months = ms / 2.6298e9; // 30.44d
if (months < 1.6) return "over 1 month";
if (months < 11.5) return `over ${Math.round(months)} months`;
const years = months / 12;
return years < 1.5 ? "over 1 year" : `over ${Math.round(years)} years`;
}
export function initChart() {
const el = document.getElementById("chart");
if (!el) return;
const ticker = el.dataset.ticker;
const chart = createChart(el, chartOptions);
// Volume first so the candlesticks draw over it in the shared bottom strip;
// its own price scale, pinned low and unlabelled, keeps it off the axis.
const volumeSeries = chart.addSeries(HistogramSeries, {
priceScaleId: "volume",
priceFormat: { type: "volume" },
lastValueVisible: false,
priceLineVisible: false,
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
const series = chart.addSeries(CandlestickSeries, {
upColor: "#2f7d4f",
downColor: "#b23b32",
wickUpColor: "#2f7d4f",
wickDownColor: "#b23b32",
borderVisible: false,
});
// Moving-average overlays on the price pane. Created up front and shown or
// hidden by the toggle row; sma200 first so the faster lines sit on top.
const overlay = (key, dashed) =>
chart.addSeries(LineSeries, {
color: OVERLAY_INK[key],
lineWidth: 2,
lineStyle: dashed ? 2 : 0,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
const overlays = {
sma200: overlay("sma200", false),
sma50: overlay("sma50", false),
ema21: overlay("ema21", true),
};
// Benchmark line (Phase 28). Created up front and shown only when the
// history payload carries `benchmark`; the toggle row gets a swatch so
// the user can hide it the same way as the SMAs.
const benchmarkSeries = chart.addSeries(LineSeries, {
color: BENCH_INK,
lineWidth: 2,
lineStyle: 2,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
visible: false,
});
// Supertrend overlay: a single line whose colour is set per point — green
// while the band trails below price (uptrend), red while it rides above
// (downtrend). One line means one value and one colour per bar, so the two
// trends can never draw at the same time; the band simply jumps to the other
// side at a flip. (Two whitespace-gapped series were tried first but the line
// connected straight across the gaps, drawing both colours at once.)
const supertrendSeries = chart.addSeries(LineSeries, {
color: SUPERTREND_UP,
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
// Earnings-date markers (Phase 25). Stocks only; the payload carries an
// `earnings` array of `YYYY-MM-DD` past dates that match candle times.
// Each draws a small ink dot above the matching bar. v5's
// createSeriesMarkers attaches to the candle series and is replaced
// wholesale on each setMarkers call.
const earningsMarkers = createSeriesMarkers(series, []);
let bars = []; // loaded candles, ascending by time
let latest = null; // last loaded payload, kept so RSI can attach on demand
// Prior-close reference line (Phase 6). The intraday ranges draw the previous
// daily close as a dashed guide so the session's move is legible at a glance;
// the daily ranges carry no `prev_close`, so the line is cleared on those.
let prevCloseLine = null;
function setPrevCloseLine(price) {
if (prevCloseLine) {
series.removePriceLine(prevCloseLine);
prevCloseLine = null;
}
if (price == null) return;
prevCloseLine = series.createPriceLine({
price,
color: "rgba(33,31,26,0.42)",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "prev close",
});
}
// The moving-average, RSI and benchmark overlays are all derived from the
// daily series, so they are meaningless on the intraday ranges. Their toggle
// buttons hide there (benchmark hides on its own when the payload has none).
const DAILY_ONLY_INDS = ["sma50", "sma200", "ema21", "rsi", "supertrend"];
// RSI lives in its own pane below the price pane and is created only while
// toggled on, so an empty second pane never lingers when it is off.
let rsiSeries = null;
function buildRsi() {
if (rsiSeries) return;
rsiSeries = chart.addSeries(
LineSeries,
{
color: RSI_INK,
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
// Pin the pane to 0..100 so the 30/70 guides are always in view.
autoscaleInfoProvider: () => ({
priceRange: { minValue: 0, maxValue: 100 },
}),
},
1,
);
for (const level of [70, 30]) {
rsiSeries.createPriceLine({
price: level,
color: "rgba(33,31,26,0.30)",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: String(level),
});
}
// Tight margins so the pinned 0..100 range fills the pane without the
// axis padding out to stray values like 120.
rsiSeries.priceScale().applyOptions({
scaleMargins: { top: 0.12, bottom: 0.12 },
});
const panes = chart.panes();
if (panes.length > 1) panes[1].setHeight(116);
if (latest) rsiSeries.setData(latest.rsi14);
}
function destroyRsi() {
if (!rsiSeries) return;
chart.removeSeries(rsiSeries);
rsiSeries = null;
// removeSeries leaves the now-empty pane behind; drop it explicitly.
if (chart.panes().length > 1) chart.removePane(1);
}
// Measure-tool overlay: a shaded band plus a readout chip, drawn by
// click-dragging across the chart to compare two points (the Google
// Finance gesture). Both are pointer-transparent so the drag stays on #chart.
const band = document.createElement("div");
band.className = "chart-band";
band.hidden = true;
const readout = document.createElement("div");
readout.className = "chart-readout";
readout.hidden = true;
el.append(band, readout);
const ts = chart.timeScale();
let anchorIdx = null; // bar index where the drag began
let curIdx = null; // bar index under the pointer now
let dragging = false;
// Map a viewport x to the nearest loaded bar index.
function barIndexAt(clientX) {
const x = clientX - el.getBoundingClientRect().left;
const logical = ts.coordinateToLogical(x);
if (logical === null) return null;
return Math.min(bars.length - 1, Math.max(0, Math.round(logical)));
}
function clearSelection() {
anchorIdx = null;
curIdx = null;
band.hidden = true;
readout.hidden = true;
}
// Position the band between the two selected bars and fill the readout
// with the % / absolute change over the interval. Called on drag and on
// every chart relayout so the band stays glued to the data.
function renderSelection() {
if (anchorIdx === null || curIdx === null || anchorIdx === curIdx) {
band.hidden = true;
readout.hidden = true;
return;
}
const a = Math.min(anchorIdx, curIdx);
const b = Math.max(anchorIdx, curIdx);
const xa = ts.logicalToCoordinate(a);
const xb = ts.logicalToCoordinate(b);
if (xa === null || xb === null) return;
const left = Math.min(xa, xb);
const width = Math.abs(xb - xa);
band.style.left = `${left}px`;
band.style.width = `${width}px`;
band.hidden = false;
const startClose = bars[a].close;
const endClose = bars[b].close;
const absChange = endClose - startClose;
const pct = startClose !== 0 ? (absChange / startClose) * 100 : 0;
const up = absChange >= 0;
readout.dataset.dir = up ? "up" : "down";
readout.innerHTML =
`<span class="chart-readout__pct">${up ? "▲" : "▼"} ` +
`${up ? "+" : ""}${pct.toFixed(2)}%</span>` +
`<span class="chart-readout__sub">${fmtMoney(absChange)} · ` +
`${fmtBarTime(bars[a].time)} → ${fmtBarTime(bars[b].time)}</span>`;
readout.hidden = false;
// Center the readout over the band, clamped to the chart's width.
const mid = left + width / 2;
const rw = readout.offsetWidth;
const max = el.clientWidth - rw - 4;
readout.style.left = `${Math.min(max, Math.max(4, mid - rw / 2))}px`;
}
el.addEventListener("pointerdown", (e) => {
if (bars.length < 2) return;
const idx = barIndexAt(e.clientX);
if (idx === null) return;
dragging = true;
anchorIdx = idx;
curIdx = idx;
el.setPointerCapture(e.pointerId);
renderSelection();
});
el.addEventListener("pointermove", (e) => {
if (!dragging) return;
const idx = barIndexAt(e.clientX);
if (idx === null) return;
curIdx = idx;
renderSelection();
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
try {
el.releasePointerCapture(e.pointerId);
} catch {
/* pointer already released */
}
// A click with no drag clears any existing selection.
if (anchorIdx === curIdx) clearSelection();
}
el.addEventListener("pointerup", endDrag);
el.addEventListener("pointercancel", endDrag);
// The change chip beside the range buttons: % and absolute move across the
// bars actually visible on the chart, so the headline figure always agrees
// with what is drawn — a deep MAX history is clamped to what legibly fits,
// and the chip then reports only that visible span, not the full dataset.
function renderRangeSummary() {
const summary = document.getElementById("range-summary");
if (!summary) return;
let lo = 0;
let hi = bars.length - 1;
const lr = ts.getVisibleLogicalRange();
if (lr) {
lo = Math.max(lo, Math.ceil(lr.from));
hi = Math.min(hi, Math.floor(lr.to));
}
if (hi <= lo) {
summary.hidden = true;
return;
}
const start = bars[lo].close;
const end = bars[hi].close;
const abs = end - start;
const pct = start !== 0 ? (abs / start) * 100 : 0;
const up = abs >= 0;
summary.dataset.dir = up ? "up" : "down";
summary.innerHTML =
`<span class="range-summary__chg num">${up ? "▲" : "▼"} ` +
`${up ? "+" : ""}${pct.toFixed(2)}%` +
`<span class="range-summary__abs">${fmtMoney(abs)}</span></span>` +
`<span class="range-summary__cap">${spanLabel(bars[lo].time, bars[hi].time)}</span>`;
summary.hidden = false;
}
// Keep the band and the range chip glued to the data when the chart relays
// out — a range change (fitContent), a resize, anything that moves the view.
ts.subscribeVisibleLogicalRangeChange(() => {
if (anchorIdx !== null && curIdx !== null) renderSelection();
renderRangeSummary();
});
// Apply a freshly fetched payload to every series at once.
function applyData(d) {
latest = d;
bars = d.candles;
series.setData(d.candles);
volumeSeries.setData(
d.candles.map((c) => ({
time: c.time,
value: c.volume,
color: c.close >= c.open ? VOLUME_UP : VOLUME_DOWN,
})),
);
overlays.sma50.setData(d.sma50);
overlays.sma200.setData(d.sma200);
overlays.ema21.setData(d.ema21);
if (rsiSeries) rsiSeries.setData(d.rsi14);
// Supertrend: one line, coloured per bar by its trend side.
supertrendSeries.setData(
(d.supertrend || []).map((p) => ({
time: p.time,
value: p.value,
color: p.up ? SUPERTREND_UP : SUPERTREND_DOWN,
})),
);
// Phase 28: benchmark overlay rides on the price pane when present.
const bench = d.benchmark || [];
benchmarkSeries.setData(bench);
// Only show the benchmark series — and the toggle for it — when the
// payload actually has one; its visibility then follows the toggle's
// is-active state (off by default).
const benchBtn = document.querySelector('[data-ind="benchmark"]');
if (benchBtn) benchBtn.hidden = bench.length === 0;
const benchOn = bench.length > 0 && (!benchBtn || benchBtn.classList.contains("is-active"));
benchmarkSeries.applyOptions({ visible: benchOn });
// Phase 25: earnings-date pips. Filter to dates inside the visible
// candle window — lightweight-charts ignores markers whose time
// does not match a candle, but trimming first keeps the payload small
// and the markers sorted ascending (the API requires it).
const earnings = d.earnings || [];
const candleTimes = new Set(d.candles.map((c) => c.time));
const markers = earnings
.filter((e) => candleTimes.has(e.time))
.map((e) => ({
time: e.time,
position: "aboveBar",
color: EARNINGS_INK,
shape: "circle",
}))
.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
earningsMarkers.setMarkers(markers);
// Phase 6: intraday ranges (1D / 1W) draw the prior close and drop the
// daily-only overlays + their toggles. Returning to a daily range restores
// them (RSI rebuilds its pane only if its toggle is still on).
setPrevCloseLine(d.intraday ? (d.prev_close ?? null) : null);
DAILY_ONLY_INDS.forEach((key) => {
const btn = document.querySelector(`[data-ind="${key}"]`);
if (btn) btn.hidden = !!d.intraday;
});
if (d.intraday) {
destroyRsi();
} else if (document.querySelector('[data-ind="rsi"]')?.classList.contains("is-active")) {
buildRsi();
}
}
// ── indicator toggles ──────────────────────────────────────────────────
function applyIndicator(key, on) {
if (key === "rsi") {
if (on) buildRsi();
else destroyRsi();
} else if (key === "volume") {
volumeSeries.applyOptions({ visible: on });
} else if (key === "benchmark") {
benchmarkSeries.applyOptions({ visible: on });
} else if (key === "supertrend") {
supertrendSeries.applyOptions({ visible: on });
} else if (overlays[key]) {
overlays[key].applyOptions({ visible: on });
}
}
document.querySelectorAll(".ind-btn").forEach((btn) => {
const key = btn.dataset.ind;
// Paint the swatch from the JS palette so the inks live in one place.
const dot = btn.querySelector(".ind-btn__dot");
if (dot) {
// Supertrend's swatch is split green/red to telegraph its two-tone line;
// the rest take their single ink from the palette.
dot.style.background =
key === "supertrend"
? `linear-gradient(90deg, ${SUPERTREND_UP} 50%, ${SUPERTREND_DOWN} 50%)`
: key === "rsi"
? RSI_INK
: key === "benchmark"
? BENCH_INK
: OVERLAY_INK[key];
}
// The template's is-active class is the initial visibility.
applyIndicator(key, btn.classList.contains("is-active"));
btn.addEventListener("click", () => {
const on = !btn.classList.contains("is-active");
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
applyIndicator(key, on);
});
});
// ── range buttons ──────────────────────────────────────────────────────
const isIntraday = (range) => range === "1D" || range === "1W";
// Fetch a range and paint it. `quiet` is the 60s intraday refresh: it skips
// the loading dim and keeps any measure selection, since it is just folding
// in newly-stored bars rather than answering a click.
async function reload(range, quiet) {
if (!quiet) el.classList.add("is-loading");
try {
const res = await fetch(
`/api/symbols/${encodeURIComponent(ticker)}/history?range=${range}`,
);
if (!res.ok) throw new Error(`history ${res.status}`);
applyData(await res.json());
chart.timeScale().fitContent();
renderRangeSummary();
} catch (err) {
if (!quiet) loaded = null;
console.error("chart load failed", err);
} finally {
if (!quiet) el.classList.remove("is-loading");
}
}
// While an intraday range is shown, re-pull every 60s so freshly-stored 15m
// bars appear without a click. The fetch only touches the local DB (no Yahoo
// call), and the live quote stream keeps the trailing bar moving in between.
let refreshTimer = null;
function stopRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
let loaded = null;
async function load(range) {
if (loaded === range) return;
loaded = range;
clearSelection();
stopRefresh();
await reload(range, false);
if (isIntraday(range)) {
refreshTimer = setInterval(() => reload(range, true), 60000);
}
}
// Live-tick the trailing intraday bar from the shared quote stream (Phase 6):
// stream.js re-broadcasts each quote as a `finance:quote` event, so the chart
// moves the last bar's close/high/low in place without a second EventSource.
window.addEventListener("finance:quote", (e) => {
const q = e.detail;
if (!q || q.ticker !== ticker || q.price == null) return;
if (!latest || !latest.intraday || !bars.length) return;
const last = bars[bars.length - 1];
last.close = q.price;
if (q.price > last.high) last.high = q.price;
if (q.price < last.low) last.low = q.price;
series.update({
time: last.time,
open: last.open,
high: last.high,
low: last.low,
close: last.close,
});
volumeSeries.update({
time: last.time,
value: last.volume,
color: last.close >= last.open ? VOLUME_UP : VOLUME_DOWN,
});
renderRangeSummary();
if (anchorIdx !== null && curIdx !== null) renderSelection();
});
window.addEventListener("pagehide", stopRefresh);
const buttons = Array.from(document.querySelectorAll("[data-range]"));
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
load(btn.dataset.range);
});
});
const active = buttons.find((b) => b.classList.contains("is-active"));
load(active ? active.dataset.range : "1Y");
}