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
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:
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
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
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
| Concept | From |
|---|
Bar | @elitechart/elitechart |
Broker | @elitechart/elitechart (Phase 2) |
Signal | this guide |
| Replay state | useChartStore |