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 kindline, 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