Skip to content
EliteChart

Building a momentum strategy

A full walkthrough — entry signal, position sizing, replay-mode backtesting, and live execution against the Broker contract.

This guide walks end-to-end through a simple momentum strategy: EMA(20) > EMA(50) crossover with volume confirmation. We define the signal, size the position, backtest it in replay mode, then wire it to a Broker for live execution.

Quick example — the signal

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

export interface Signal {
  readonly time: number;
  readonly side: 'long' | 'short' | 'flat';
  readonly score: number;
}

export function emaCrossSignal(bars: ReadonlyArray<Bar>): ReadonlyArray<Signal> {
  const fast = ema(bars.map((b) => b.close as number), 20);
  const slow = ema(bars.map((b) => b.close as number), 50);
  const volMa = sma(bars.map((b) => b.volume as number), 20);
  const out: Signal[] = [];
  for (let i = 1; i < bars.length; i += 1) {
    const cross = fast[i] > slow[i] && fast[i - 1] <= slow[i - 1];
    const volOk = (bars[i].volume as number) > volMa[i] * 1.2;
    out.push({
      time: bars[i].time as number,
      side: cross && volOk ? 'long' : 'flat',
      score: (fast[i] - slow[i]) / slow[i],
    });
  }
  return out;
}

function ema(arr: ReadonlyArray<number>, p: number): ReadonlyArray<number> {
  const k = 2 / (p + 1);
  const out = [arr[0]];
  for (let i = 1; i < arr.length; i += 1) out.push(k * arr[i] + (1 - k) * out[i - 1]);
  return out;
}
function sma(arr: ReadonlyArray<number>, p: number): ReadonlyArray<number> {
  const out: number[] = [];
  let sum = 0;
  for (let i = 0; i < arr.length; i += 1) {
    sum += arr[i];
    if (i >= p) sum -= arr[i - p];
    out.push(i >= p - 1 ? sum / p : NaN);
  }
  return out;
}

How it works

Signal definition. EMA(20) crosses above EMA(50) and the bar's volume is at least 1.2× the 20-bar volume MA. That's the long trigger. The score is the percent gap between the two EMAs — used later for position sizing.

Position sizing. Risk a fixed percentage of equity per trade, sized so that hitting the stop loses exactly that much:

code
function sizeBy(equity: number, entry: number, stop: number, riskPct: number): number {
  const risk = equity * riskPct;
  const distance = Math.abs(entry - stop);
  return distance === 0 ? 0 : risk / distance;
}

Backtesting in replay. Switch the chart into replay mode, step through bars one at a time, and call onBar(bar) from your strategy module. Track equity in a closure, log fills.

Live execution. Same module, different driver — instead of stepping replay, subscribe to the live datafeed and forward every bar to the strategy. On a long signal, call broker.placeOrder({ side: 'long', symbol, quantity }).

Backtest scaffolding

code
interface BacktestResult {
  readonly trades: number;
  readonly wins: number;
  readonly losses: number;
  readonly netReturnPct: number;
  readonly maxDrawdownPct: number;
}

export function backtest(
  bars: ReadonlyArray<Bar>,
  signals: ReadonlyArray<Signal>,
  riskPct = 0.01,
): BacktestResult {
  let equity = 10_000;
  let peak = equity;
  let maxDD = 0;
  let position: { entry: number; qty: number } | null = null;
  let wins = 0;
  let losses = 0;
  let trades = 0;

  for (let i = 0; i < bars.length; i += 1) {
    const sig = signals[i];
    const close = bars[i].close as number;

    if (position === null && sig.side === 'long') {
      const stop = close * 0.97;
      const qty = sizeBy(equity, close, stop, riskPct);
      position = { entry: close, qty };
      trades += 1;
    } else if (position !== null && (sig.side === 'flat' || close <= position.entry * 0.97)) {
      const pnl = (close - position.entry) * position.qty;
      equity += pnl;
      pnl > 0 ? wins += 1 : losses += 1;
      position = null;
    }
    peak = Math.max(peak, equity);
    maxDD = Math.max(maxDD, (peak - equity) / peak);
  }

  return {
    trades, wins, losses,
    netReturnPct: (equity - 10_000) / 10_000,
    maxDrawdownPct: maxDD,
  };
}

function sizeBy(eq: number, entry: number, stop: number, risk: number): number {
  const risked = eq * risk;
  const dist = Math.abs(entry - stop);
  return dist === 0 ? 0 : risked / dist;
}

Variations

Add a take-profit

Inside the backtest loop, also exit when close >= position.entry * 1.06. Tracks a 2:1 reward-to-risk.

Live execution

code
import type { Broker } from '@elitechart/elitechart';

export function liveDriver(broker: Broker, signal: Signal, symbol: string, equity: number) {
  if (signal.side !== 'long') return;
  const close = signal.score; // your latest mid here
  const stop = close * 0.97;
  const qty = sizeBy(equity, close, stop, 0.01);
  void broker.placeOrder({ side: 'long', symbol, quantity: qty, type: 'market' } as never);
}

API

ConceptFrom
Bar@elitechart/elitechart
Broker@elitechart/elitechart (Phase 2)
Signalthis guide
Replay stateuseChartStore