Financial workbench

A year of daily prices as a trading terminal, free in the grid package: rows to candles with rowsToOhlcSpec, a daily / weekly toggle through resampleOhlc, Bollinger bands or an EMA on the price, volume, RSI, MACD, stochastic, ATR and OBV panes stacked under it with one x axis and one crosshair, a last-price pill, earnings and dividend flags with tooltips, drawing tools (trend line, ray, Fibonacci, rectangle, arrow, note) stored as data points, range presets, Ctrl + wheel zoom, PDF and print. Below, the same rows in the grid panel with Candlestick picked, indicator chips, the chart builder and the link toggle.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.

About this example

A year of daily prices as a trading terminal in Svelte 5, free in the grid package. rowsToOhlcSpec turns rows into candles with volume and a weekly toggle rolls them up through resampleOhlc. Bollinger bands, an EMA, an SMA or VWAP ride on the price; volume, RSI, MACD, stochastic, ATR and OBV panes stack under it with one x axis, one gutter and one crosshair. A last-price pill sits on the axis, earnings and dividend flags carry tooltips, and the drawing tools store trend lines, rays, Fibonacci retracements, rectangles, arrows and notes as data points that survive zoom and the daily to weekly switch. Range presets, Ctrl + wheel zoom, PDF export and print. Below, the same rows in the grid panel with Candlestick picked, indicator chips, the chart builder and the link toggle.

A year of daily prices as a trading-terminal layout, all of it free in @svgrid/grid:

  • rowsToOhlcSpec turns the rows into candles (with volume), and bucket: 'week' rolls them up through resampleOhlc.
  • Bollinger bands and an EMA 20 ride on the price as overlays; volume, RSI and MACD are panes under it, stacked by SvChartPanes with one x axis, one gutter and one crosshair.
  • lastPriceLine pins the last close on the axis; earnings and dividend flags are annotations with shape: 'flag' and a tooltip text.
  • drawable puts the drawing tools in the toolbar: trend lines, rays, Fibonacci retracements, rectangles, arrows, notes, all stored as data points so they survive zoom and the daily / weekly toggle.
  • Presets, Ctrl + wheel zoom, Shift + drag pan, PDF and print.

The second half is the grid's own chart panel on the same rows, with Candlestick picked, the indicator chips, the Build button's gallery and Format tab, and the link toggle that freezes a chart while you filter.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: day (Day), open (Open), high (High), low (Low), close (Close), volume (Volume)

Frequently asked questions

Where do the indicator panes come from?

indicatorPane builds a complete spec for one indicator from the price spec, and SvChartPanes stacks those specs under the price chart in a private sync group. Each pane is a full SvChart, so tooltips, keyboard navigation, export and the screen-reader table come along.

Do the drawings survive a zoom or new data?

Yes. A drawing is anchored in data space: x is a category label or date and y a value. The chart resolves it to pixels on every layout, so a trend line drawn on Tuesday's close stays on Tuesday's close after a zoom, a resize or the weekly roll-up.

How is the PDF made without a PDF library?

The chart is rasterised through the same path as the PNG export, re-encoded as JPEG and placed on an A4 or Letter page by a small writer that emits the catalog, page, font, image and content stream itself. Title, subtitle and caption are Helvetica text.

Related documentation

Source code (437-chart-financial-workbench.svelte)

<!-- Documented in: docs/help/charts/financial.md -->
<script lang="ts">
  /**
   * 437. Financial workbench
   * ------------------------
   * A year of daily prices as a trading-terminal layout, all of it free in
   * @svgrid/grid:
   *
   * - `rowsToOhlcSpec` turns the rows into candles (with volume), and
   *   `bucket: 'week'` rolls them up through `resampleOhlc`.
   * - Bollinger bands and an EMA 20 ride on the price as `overlay`s; volume,
   *   RSI and MACD are panes under it, stacked by `SvChartPanes` with one
   *   x axis, one gutter and one crosshair.
   * - `lastPriceLine` pins the last close on the axis; earnings and dividend
   *   flags are annotations with `shape: 'flag'` and a tooltip `text`.
   * - `drawable` puts the drawing tools in the toolbar: trend lines, rays,
   *   Fibonacci retracements, rectangles, arrows, notes, all stored as data
   *   points so they survive zoom and the daily / weekly toggle.
   * - Presets, Ctrl + wheel zoom, Shift + drag pan, PDF and print.
   *
   * The second half is the grid's own chart panel on the same rows, with
   * Candlestick picked, the indicator chips, the Build button's gallery and
   * Format tab, and the link toggle that freezes a chart while you filter.
   */
  import {
    SvChartPanes, SvGrid, rowsToOhlcSpec, tableFeatures, rowSortingFeature, columnFilteringFeature,
    type ChartAnnotation, type ChartDrawing, type ChartIndicatorSpec, type ChartSpec, type GridColumns, type SeriesOverlay,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Bar = { day: string; open: number; high: number; low: number; close: number; volume: number }
  const rows: Bar[] = (() => {
    let seed = 42
    const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
    const start = Date.UTC(2025, 8, 15)
    const out: Bar[] = []
    let price = 182
    for (let i = 0; i < 365; i += 1) {
      const d = new Date(start + i * 86_400_000)
      const dow = d.getUTCDay()
      if (dow === 0 || dow === 6) continue
      const open = price
      const drift = Math.sin(i / 40) * 0.6 + (rnd() - 0.47) * 3.2
      const close = Math.max(60, open + drift)
      const high = Math.max(open, close) + rnd() * 2.2
      const low = Math.min(open, close) - rnd() * 2.2
      out.push({ day: d.toISOString().slice(0, 10), open: round2(open), high: round2(high), low: round2(low), close: round2(close), volume: Math.round(1_800_000 + rnd() * 1_400_000 + Math.abs(close - open) * 500_000) })
      price = close
    }
    return out
  })()
  function round2(v: number) { return Math.round(v * 100) / 100 }

  let bucket = $state<'day' | 'week'>('day')
  let overlay = $state<SeriesOverlay | ''>('bb:20:2')
  let indicators = $state<ChartIndicatorSpec['kind'][]>(['volume', 'rsi', 'macd'])
  let drawings = $state<ChartDrawing[]>([
    { id: 'support', kind: 'hray', points: [{ x: rows[40]!.day, y: round2(rows[40]!.low) }], color: '#16a34a' },
  ])
  const flags: ChartAnnotation[] = [
    { at: { category: rows[62]!.day }, label: 'E', shape: 'flag', text: `Earnings ${rows[62]!.day}: EPS 1.42 vs 1.30 expected` },
    { at: { category: rows[126]!.day }, label: 'D', shape: 'flag', color: '#16a34a', text: `Dividend 0.24, ex-date ${rows[126]!.day}` },
    { at: { category: rows[188]!.day }, label: 'E', shape: 'flag', text: `Earnings ${rows[188]!.day}: EPS 1.51 vs 1.47 expected` },
  ]
  const ALL_INDICATORS: Array<{ kind: ChartIndicatorSpec['kind']; label: string }> = [
    { kind: 'volume', label: 'Volume' }, { kind: 'rsi', label: 'RSI' }, { kind: 'macd', label: 'MACD' },
    { kind: 'stochastic', label: 'Stochastic' }, { kind: 'atr', label: 'ATR' }, { kind: 'obv', label: 'OBV' },
  ]
  const toggle = (k: ChartIndicatorSpec['kind']) => (indicators = indicators.includes(k) ? indicators.filter((x) => x !== k) : [...indicators, k])

  const spec = $derived.by<ChartSpec>(() => {
    const base = rowsToOhlcSpec(rows, {
      date: 'day', open: 'open', high: 'high', low: 'low', close: 'close', volume: 'volume',
      ...(bucket === 'week' ? { bucket: 'week' } : {}),
      label: 'ACME', lastPriceLine: true,
    })
    return {
      ...base,
      title: `ACME ${bucket === 'week' ? 'weekly' : 'daily'}`,
      series: base.series.map((s) => ({ ...s, ...(overlay ? { overlay } : {}) })),
      annotations: bucket === 'day' ? flags : [],
      drawings,
      valueFormat: 'currency',
      currency: 'USD',
      height: 300,
    }
  })
  const paneSpecs = $derived<ChartIndicatorSpec[]>(indicators.map((kind) => ({ kind, height: kind === 'volume' ? 80 : 100 })))

  const columns: GridColumns<Bar> = [
    { field: 'day', header: 'Day', width: 110, cellDataType: 'date' },
    { field: 'open', header: 'Open', width: 90, align: 'right', cellDataType: 'number' },
    { field: 'high', header: 'High', width: 90, align: 'right', cellDataType: 'number' },
    { field: 'low', header: 'Low', width: 90, align: 'right', cellDataType: 'number' },
    { field: 'close', header: 'Close', width: 90, align: 'right', cellDataType: 'number' },
    { field: 'volume', header: 'Volume', width: 110, align: 'right', cellDataType: 'number' },
  ]
</script>

<section class="wrap">
  <header class="chrome">
    <label class="ctl">
      Bars
      <select bind:value={bucket}>
        <option value="day">Daily</option>
        <option value="week">Weekly</option>
      </select>
    </label>
    <label class="ctl">
      Overlay
      <select bind:value={overlay}>
        <option value="">none</option>
        <option value="bb:20:2">Bollinger 20 / 2</option>
        <option value="ema:20">EMA 20</option>
        <option value="sma:50">SMA 50</option>
        <option value="vwap">VWAP</option>
      </select>
    </label>
    <span class="chips" role="group" aria-label="Indicator panes">
      {#each ALL_INDICATORS as ind (ind.kind)}
        <button type="button" class="chip" class:is-on={indicators.includes(ind.kind)} aria-pressed={indicators.includes(ind.kind)} onclick={() => toggle(ind.kind)}>{ind.label}</button>
      {/each}
    </span>
    <span class="note">{drawings.length} drawing{drawings.length === 1 ? '' : 's'}. Ctrl + wheel zooms, Shift + drag pans, the presets jump.</span>
  </header>

  <div class="pane demo-workbench">
    <SvChartPanes
      spec={spec}
      indicators={paneSpecs}
      zoomable={{ wheel: 'modifier', pinch: true, pan: 'shift' }}
      rangePresets
      drawable
      onDrawingsChange={(d) => (drawings = d)}
      legend={false}
      animate={{ enter: 'wipe' }}
      contextMenu
    />
  </div>

  <div class="pane demo-panel">
    <div class="bar">
      <span class="note">The same rows in the grid's own chart panel: Candlestick picked, indicator chips, the Build button's gallery and Format tab, and the link toggle.</span>
    </div>
    <div class="grid-host">
      <SvGrid
        data={rows}
        {columns}
        {features}
        sortable
        filterable
        rowHeight={28}
        containerHeight="100%"
        fitColumns
        responsive
        charting={{
          defaultOpen: true, defaultType: 'candlestick', position: 'right', width: 520,
          zoom: { wheel: 'modifier', pan: 'shift' }, rangePresets: true, export: true, contextMenu: true,
        }}
      />
    </div>
  </div>
</section>

<style>
  .wrap {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    gap: 10px;
    overflow: auto;
  }
  .chrome {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    flex: none;
  }
  .note {
    font-size: 12px;
    color: var(--sg-muted, #64748b);
  }
  .ctl {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 12px;
    color: var(--sg-fg, #0f172a);
  }
  .ctl select {
    font: inherit;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 6px;
    background: var(--sg-bg, #fff);
    color: inherit;
    padding: 2px 6px;
  }
  .chips {
    display: inline-flex;
    gap: 4px;
    flex-wrap: wrap;
  }
  .chip {
    font: inherit;
    font-size: 11px;
    line-height: 1;
    padding: 4px 8px;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 999px;
    background: var(--sg-bg, #fff);
    color: var(--sg-fg, #0f172a);
    cursor: pointer;
  }
  .chip.is-on {
    background: var(--sg-accent, #2563eb);
    border-color: var(--sg-accent, #2563eb);
    color: #fff;
  }
  .pane {
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 10px;
    background: var(--sg-bg, #fff);
    padding: 8px 10px;
    min-width: 0;
    flex: none;
  }
  .bar {
    margin-bottom: 6px;
  }
  .grid-host {
    height: 460px;
    min-width: 0;
  }
</style>

View this example on GitHub

More Charts examples

  • Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
  • Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
  • Candlestick / OHLC - Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.
  • Box plot + error bars - Distribution rather than average: box plots with the 1.5 IQR whisker rule and individual outliers, next to the same data as a bar chart of the means with error bars. two regions with nearly identical means sit side by side in the bars and look nothing alike in the boxes. boxStats() summarises a raw sample, rowsToBoxSpec() does it per group, and ChartSeries.errors annotates any existing mark without changing its type.
  • Axes, titles and styling - The chart hands the decisions back: a numeric x axis that spreads payload sizes the way the numbers do, pinned domains with a fixed tick interval and a formatter per axis, grid lines on or off, a shaded budget band, a vertical reference line, per-point markers and colours, a stepped dashed series on the right axis, a title / subtitle / caption, a legend on any side, and a tooltip snippet in single-series mode. Below it, 50,000 monitoring readings decimated to one point per pixel with LTTB or min / max - toggle it off to see the cost.