Skip to content
EliteChart

Writing an indicator

Build a custom indicator from scratch — the IndicatorPlugin contract, compute pipeline, fixture-based testing.

ChartForge's indicator pipeline is open. Implementing a custom indicator means writing a pure function (bars, params) => series and wrapping it in the IndicatorPlugin contract. This guide walks through a complete example — a "Price Above EMA" boolean indicator that paints a green dot below every bar where close > EMA.

Quick example

code
import type { IndicatorPlugin, Bar } from '@elitechart/core';

interface PriceAboveEmaParams {
  readonly period: number;
}

export const priceAboveEma: IndicatorPlugin<PriceAboveEmaParams> = {
  id: 'price-above-ema',
  name: 'Price Above EMA',
  paneKind: 'overlay',
  defaultParams: { period: 20 },
  compute(bars: ReadonlyArray<Bar>, params: PriceAboveEmaParams) {
    const { period } = params;
    const out: Array<{ time: number; value: number }> = [];
    let ema = 0;
    const k = 2 / (period + 1);
    for (let i = 0; i < bars.length; i += 1) {
      const close = bars[i].close as number;
      ema = i === 0 ? close : k * close + (1 - k) * ema;
      out.push({ time: bars[i].time as number, value: close > ema ? 1 : 0 });
    }
    return [{ id: 'flag', name: 'Above', kind: 'marker', data: out }];
  },
};

Register and add to the chart:

code
import { useChartStore } from '@elitechart/elitechart';
import { priceAboveEma } from './price-above-ema';

useChartStore.getState().registerIndicator(priceAboveEma);
useChartStore.getState().addIndicator({ id: 'pae-1', name: 'Price Above EMA', params: { period: 20 } });

How it works

The contract has five fields:

  • id — unique string identifier.
  • name — display name in the Indicators modal.
  • paneKind — 'overlay' or 'pane'. Overlay draws on the price pane; pane gets its own.
  • defaultParams — shape + default values shown in the modal.
  • compute(bars, params) — pure function. Must be deterministic and side-effect-free.

compute returns an array of output series. Each series has its own id, name, and kind — line, histogram, marker, or band. The renderer picks the right drawing strategy from the kind.

The pipeline runs compute on every bar update. For O(N) algorithms (EMA, SMA, etc.) this is fine — 10k bars × 30 indicators × 60 fps holds the budget. For O(N²) algorithms, hoist state into a closure:

code
compute(bars, params) {
  const cache: number[] = [];
  // ...your loop reuses cache...
}

Testing with fixtures

Every indicator in @elitechart/indicators ships with a fixture file at __fixtures__/<name>.json — input bars + expected output. The unit test asserts byte-for-byte equality.

code
// __tests__/price-above-ema.test.ts
import { describe, it, expect } from 'vitest';
import { priceAboveEma } from '../src/price-above-ema';
import fixture from './__fixtures__/price-above-ema.json';

describe('price-above-ema', () => {
  it('matches reference fixture (period=20)', () => {
    const result = priceAboveEma.compute(fixture.bars, { period: 20 });
    expect(result).toEqual(fixture.expected);
  });
});

Cite your fixture source in a top-of-file comment — a published paper, a reference implementation you can link, or your own verified output. Reviewers check this.

Variations

Multi-output indicator (band)

code
compute(bars, params) {
  return [
    { id: 'mid', name: 'Mid', kind: 'line', data: midSeries },
    { id: 'upper', name: 'Upper', kind: 'line', data: upperSeries },
    { id: 'lower', name: 'Lower', kind: 'line', data: lowerSeries },
    { id: 'band', name: 'Band', kind: 'band', data: { upper: upperSeries, lower: lowerSeries } },
  ];
}

Pane indicator with a horizontal threshold

code
{
  paneKind: 'pane',
  defaultParams: { period: 14, overbought: 70, oversold: 30 },
  compute(bars, params) {
    return [
      { id: 'rsi', name: 'RSI', kind: 'line', data: rsiSeries },
      { id: 'ob',  name: 'Overbought', kind: 'horizontal', value: params.overbought },
      { id: 'os',  name: 'Oversold',   kind: 'horizontal', value: params.oversold },
    ];
  }
}

API

FieldTypeRequired
idstringyes
namestringyes
paneKind'overlay' | 'pane'yes
defaultParamsobjectyes
compute(bars, params) => series[]yes
validate?(params) => string | nullno
description?stringno