@watchgold/financial-charts
v0.1.0
Published
Financial charting primitives for React: futures contract-roll gap handling, moving averages, CME contract and session utilities, a watermark-ready chart frame for recharts, and a stale-data banner.
Maintainers
Readme
@watchgold/financial-charts
Financial charting primitives for React: futures contract-roll gap handling, moving averages, CME contract and session utilities, a watermark-ready chart frame for recharts, and a stale-data banner.
Why
A "continuous" futures price series is stitched from whichever contract is the front month at the time. Every time the front month rolls (e.g. GCQ6 → GCZ6), the stitched series splices the calendar spread between the two contracts into the line — in gold, a step on the order of $50 that is a contract change, not a market move. Drawn naively it becomes a fake cliff on the chart, and a naive last-minus-first change quotes the spread as if the market moved by it.
This package breaks the line at each roll and labels it instead:
insertRollBreaksinserts a synthetic gap point per roll (null the price → the line breaks withconnectNulls={false}), optionally with a dashed "bridge" value so the eye still gets a continuous line while the solid stroke only draws real moves.rollAdjustedChangereports the sum of intra-contract moves — exactly the movement the broken line draws — instead of quoting the spread.
Install
npm install @watchgold/financial-chartsThe root entry (@watchgold/financial-charts) is pure TypeScript with zero dependencies. The ./react entry needs react and recharts, which are declared as optional peer dependencies — install them if you use the components:
npm install react rechartsQuick start: a roll-aware price line
import { insertRollBreaks, rollAdjustedChange } from '@watchgold/financial-charts';
import { ChartFrame, ChartWatermark } from '@watchgold/financial-charts/react';
import { Line, LineChart, ReferenceLine, Tooltip, XAxis, YAxis } from 'recharts';
type Bar = { time: string; price: number | null; contract: string | null; bridge?: number | null };
// bars: one point per candle, tagged with the contract it was built from.
const { displayData, rollBoundaries } = insertRollBreaks<Bar>(bars, {
contractOf: (p) => p.contract,
// Null the price so the line breaks at the roll (pair with connectNulls={false}).
gapPoint: (prev, gapTime) => ({ time: gapTime, price: null, contract: prev.contract }),
// Optional dashed connector across the gap.
bridge: { priceOf: (p) => p.price, set: (p, bridge) => ({ ...p, bridge }) },
});
// Header change that matches the chart: spread excluded, only real moves.
const change = rollAdjustedChange(bars, (p) => p.price, (p) => p.contract);
export function PriceChart() {
return (
<ChartFrame height={320} watermark={<ChartWatermark src="/your-logo.svg" width={144} height={24} />}>
<LineChart data={displayData}>
<XAxis dataKey="time" />
<YAxis domain={['auto', 'auto']} />
<Tooltip />
{rollBoundaries.map((b) => (
<ReferenceLine key={b.time} x={b.time} strokeDasharray="4 4" label={`${b.from} → ${b.to}`} />
))}
<Line dataKey="bridge" stroke="#d4a017" strokeDasharray="4 4" dot={false} isAnimationActive={false} />
<Line dataKey="price" stroke="#d4a017" dot={false} connectNulls={false} />
</LineChart>
</ChartFrame>
);
}Utilities (root entry, zero dependencies)
Contract rolls — insertRollBreaks, rollAdjustedChange
See the quick start. insertRollBreaks can be applied more than once to the same series (once per contract-bearing line) as long as each pass's gap points carry the other passes' contract fields through. Unparseable timestamps still record the roll boundary; only the synthetic gap point is skipped.
Contracts — decodeCmeContractMonth, formatContractTicker, CME_MONTH_CODES
decodeCmeContractMonth('GCZ6', 'GC'); // 'Dec 2026' (current/future months only, else null)
decodeCmeContractMonth('SIU25', 'SI'); // 'Sep 2025'
formatContractTicker('GCM26'); // 'GCM6' (short ticker form)decodeCmeContractMonth accepts { now } to anchor the single-digit-year decade heuristic and the past-month rejection (inject it in tests).
Sessions — getEasternSessionOpenMs, getEasternMidnightMs, cmeHourlySnapshotCaptureMs, wallClockMs, timeZoneOffsetMs
getEasternSessionOpenMs(referenceMs?)— the most recent CME Globex session open (18:00 America/New_York); sessions run 18:00 ET → 17:00 ET the next day, so this is "today's trading" for futures.getEasternMidnightMs(referenceMs?)— 00:00 ET of the Eastern calendar date, for calendar-day intraday views.cmeHourlySnapshotCaptureMs(nowMs?)— CME snapshot-license timing: a redistributed price may only be a single snapshot captured at :05 each hour and published no earlier than :15; this returns the capture instant of the most recent snapshot already publishable.wallClockMs(referenceMs, hour, timeZone)/timeZoneOffsetMs(utcMs, timeZone)— DST-correct wall-clock math via the Intl API, no time-zone database dependency.
Delayed change — computeDelayedWindowChange
For delayed-redistribution feeds (e.g. CME Smart Stream): drops candles newer than delayMs (default 10 minutes), then computes last-minus-first close across windowMs (default 24 hours, since a futures session spans two calendar dates). Returns { price, change, pct }, with change/pct null when fewer than two candles are usable. Compute every ticker and card from this one function so the number shown next to a chart always matches the chart.
Candles — candleEndTime, DEFAULT_INTRADAY_INTERVAL_MS
Intraday candles are conventionally labeled with their bucket-OPEN time, but a price line renders the candle's close — the value at the END of the bucket. candleEndTime(timestamp, interval) shifts the label to the bucket close (a 1:55–2:00 bar's close shows as ~2:00, not "1:55"). Daily/unknown intervals pass through unchanged; supply your own interval map as the third argument if needed.
Indicators — simpleMovingAverage, buildDailyMaSeries, maConfigsForPeriod, defaultMaConfigs, defaultMaWindowsByPeriod
simpleMovingAverage(closes, 20); // null before the window fills, then trailing averages
// MAs computed over the FULL history, then sliced to the visible window,
// so the average is already correct at the first visible point.
const series = buildDailyMaSeries(fullDailyCandles, '1M');
// → [{ time, price, open, high, low, ma5, ma20 }, …]maConfigsForPeriod(period) resolves which overlays a chart period gets (1M → MA5+MA20, 1Y → MA50+MA200, …); pass custom configs / windowsByPeriod / displayDaysByPeriod to re-map any of it.
Components (./react entry)
import { ChartFrame, ChartWatermark, StaleDataBanner } from '@watchgold/financial-charts/react';ChartFrame + ChartWatermark
ChartFrame is a drop-in replacement for recharts' ResponsiveContainer with a watermark slot. The watermark is any React node — typically <ChartWatermark src="/your-logo.svg" />, which renders an absolutely-positioned, pointer-events: none, aria-hidden image (defaults: upper right, opacity 0.3) so recharts tooltips and hover keep working underneath. Pass children to ChartWatermark to render arbitrary content (a text credit, a custom SVG) instead of an image.
StaleDataBanner
A translucent amber banner flagging that on-screen data may be out of date, with an optional refresh button (disabled with a spinning icon while isRefreshing). Strings come in as props (message, refreshLabel) so any i18n layer can supply them; a compact variant fits card headers.
<StaleDataBanner
message={`Data may be stale — last updated ${lastUpdated}`}
onRefresh={refetch}
isRefreshing={isFetching}
/>Both components are styled with inline styles only — no Tailwind, no CSS import, no styling framework required — and the icons are small hand-authored inline SVGs, so the ./react entry brings no dependencies beyond react and recharts.
Extracted from the production codebase of WatchGold, a precious-metals market-data platform.
MIT
