Build your first Indicator

Build a real EMA crossover step by step, one idea at a time, from a single average to a finished Indicator with a mark on every cross. On the last page you…

Build a real EMA crossover step by step, one idea at a time, from a single average to a finished Indicator with a mark on every cross. On the last page you drew one line. Now we build something traders actually watch: when a fast average rises above a slow one, momentum is turning up. We get there in four small steps, each adding exactly one idea. Type along, press Run after each step, and watch the chart change. Everything on this page is a complete file: paste it under the //@lang=wrun-ts marker and it runs.

Step 1: One moving average

Start from the template and change one thing: swap the simple average for an exponential one. Ema weights recent bars more heavily, so it turns a little sooner than Sma. The declarations, the state, and the four functions are the ones you already know.

import { input, line, ohlcv, output, overlay, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_avg } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Ema } from "./sdk/ta";

param("period", 20, { min: 1, max: 200, description: "EMA length in bars" });
input("close", ohlcv.close);
output("avg", line, overlay, { color: "#2563eb", width: 2, description: "EMA of the close" });

let ema = new Ema(20);
let value: f64 = NaN;

export function init(): void {
  ema = new Ema(i32(p_period()));
}

export function state(): i32 {
  value = ema.update(in_close());
  return isNaN(value) ? 0 : 1;
}

export function finalize(): void {
  out_avg(value);
  emitRow();
}

export function reset(): void {
  ema.reset();
  value = NaN;
}

Same three parts as before, with two small changes. new Ema(...) replaces new Sma(...), and the output carries a color, a width, and a description: the description is the legend text, and the color and width are the line's look. Styling is declared on the output, never computed in the code, which is why the chart can restyle a line without recompiling.

On the chart you'll see the jagged candles and, riding through the middle of them, a calm blue line. That blue line is the trend.

Step 2: A second, faster average

A moving average is an average that slides forward one bar at a time. The period controls how much it smooths: a large period (like 21) reacts slowly and shows the broad trend, while a small period (like 9) hugs price closely and reacts fast.

That difference in speed is the whole idea behind a crossover. If we draw a fast average and a slow average together, the fast one leads and the slow one lags. The moment the fast crosses above the slow is the moment short-term momentum has pulled ahead of the longer trend. Here are both, each with its own setting:

import { input, line, ohlcv, output, overlay, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_fast, out_slow } from "./gen/outputs";
import { p_fast, p_slow } from "./gen/params";
import { Ema } from "./sdk/ta";

param("fast", 9, { min: 1, max: 200, description: "Fast EMA length" });
param("slow", 21, { min: 2, max: 400, description: "Slow EMA length" });
input("close", ohlcv.close);
output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast EMA of the close" });
output("slow", line, overlay, { color: "#f97316", width: 2, description: "Slow EMA of the close" });

let fast = new Ema(9);
let slow = new Ema(21);
let fastValue: f64 = NaN;
let slowValue: f64 = NaN;

export function init(): void {
  fast = new Ema(i32(p_fast()));
  slow = new Ema(i32(p_slow()));
}

export function state(): i32 {
  const close = in_close();
  fastValue = fast.update(close);
  slowValue = slow.update(close);
  return isNaN(slowValue) ? 0 : 1;
}

export function finalize(): void {
  out_fast(fastValue);
  out_slow(slowValue);
  emitRow();
}

export function reset(): void {
  fast.reset();
  slow.reset();
  fastValue = NaN;
  slowValue = NaN;
}

Every new output needs its out_<name> import and a write in finalize(); every new param needs its p_<name> import and a read in init(). Notice that state() waits for the slower average (isNaN(slowValue)), so both lines start on the same bar instead of the fast one appearing twelve bars early.

Now there are two lines: a blue fast EMA (period 9) and an orange slow EMA (period 21). Watch how the blue line whips around more, while the orange one glides. Where the blue line pokes up through the orange one, momentum is flipping upward. That crossing point is what we want to catch.

Step 3: Detecting the cross

Now we want to know the exact moment the fast line crosses above the slow line, not eyeball it. The TA kit has a class for precisely this: Cross. Construct one in init(), feed it both values once per bar, and it answers +1 on the single bar where the first value rises from at-or-below the second to above it, -1 on the opposite cross, and 0 everywhere else (including any bar where either side is still NaN).

let cross = new Cross();
let crossed: i32 = 0;

// in state(), after both averages are updated:
crossed = cross.update(fastValue, slowValue);

Read it out loud: "crossed is plus one when fast crosses over slow." On most bars crossed is 0. On the one bar where the blue line breaks above the orange line, it flips to 1. That single 1 is our signal. Next we draw something there.

There is no if around a plot in an Indicator, because an Indicator never calls a plot function. A decision is a number, so the cross becomes an output of its own: 1 on the signal bar, 0 otherwise. Outputs that exist only to carry a decision are declared with the plot kind none: computed every bar, drawn never.

Step 4: Marking the cross on the chart

A 0 or 1 is invisible. To see the signal we draw a mark, but only on the bars where the cross happened. An output declared with the plot kind shape draws a mark at its value on every bar it has one; the shape_where option names a gate output, and the mark draws only on bars where the gate is nonzero. Put the mark's value at the bar's low so it sits just under the candle, out of the way of the price action.

import { input, line, none, ohlcv, output, overlay, param, shape } from "./sdk/declare";
import { in_close, in_low } from "./gen/inputs";
import { emitRow, out_cross_up, out_fast, out_is_cross_up, out_slow } from "./gen/outputs";
import { p_fast, p_slow } from "./gen/params";
import { Cross, Ema } from "./sdk/ta";

// 1. Two settings.
param("fast", 9, { min: 1, max: 200, description: "Fast EMA length" });
param("slow", 21, { min: 2, max: 400, description: "Slow EMA length" });
// 2. The chart's own close for the averages, and its low to place the mark.
input("close", ohlcv.close);
input("low", ohlcv.low);
// 3. Two lines on the price pane.
output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast EMA of the close" });
output("slow", line, overlay, { color: "#f97316", width: 2, description: "Slow EMA of the close" });
// 4. A mark at the bar's low, drawn only where the gate is 1.
output("cross_up", shape, overlay, { color: "#16a34a", shape_where: "is_cross_up", description: "Fast EMA crossed above slow EMA" });
output("is_cross_up", none);

let fast = new Ema(9);
let slow = new Ema(21);
let cross = new Cross();
let fastValue: f64 = NaN;
let slowValue: f64 = NaN;
let low: f64 = NaN;
let crossed: i32 = 0;

export function init(): void {
  fast = new Ema(i32(p_fast()));
  slow = new Ema(i32(p_slow()));
  cross = new Cross();
}

export function state(): i32 {
  const close = in_close();
  low = in_low();
  fastValue = fast.update(close);
  slowValue = slow.update(close);
  crossed = cross.update(fastValue, slowValue);
  return isNaN(slowValue) ? 0 : 1;
}

export function finalize(): void {
  out_fast(fastValue);
  out_slow(slowValue);
  out_cross_up(low);
  out_is_cross_up(crossed == 1 ? 1.0 : 0.0);
  emitRow();
}

export function reset(): void {
  fast.reset();
  slow.reset();
  cross.reset();
  fastValue = NaN;
  slowValue = NaN;
  low = NaN;
  crossed = 0;
}

A few things to unpack here:

  • output("cross_up", shape, overlay, ...) draws a mark instead of a line. Its value is the bar's low, so the mark sits under the candle; its color is the mark's color.
  • shape_where: "is_cross_up" names the gate. The mark draws only on bars where is_cross_up is nonzero. Everywhere else, nothing is drawn.
  • output("is_cross_up", none) is the decision itself: 1 on the cross bar, 0 otherwise. It never draws, but it exists, and once the Indicator is published and installed it is a metric an alert can watch crossing above 0.
  • crossed == 1 ? 1.0 : 0.0 turns the Cross answer into that output. Outputs are always f64, so the integer answer becomes a float here.

Press Run, and you'll see the two EMAs weaving across each other, with a green mark under the candle at each upward cross. That is a genuine momentum signal, built from the same three parts you learned on page one plus two new ideas: a class that detects a condition (Cross) and an output that draws only when another output says so (shape with shape_where).

Step 5: Change a setting without recompiling

Open the overlay's settings on the chart. Every param(...) is a setting there: change fast to 12 and slow to 26 and both lines recompute without recompiling. To change what the setting offers (its default or its range), edit the declaration and Run again.

Save, download, publish

Save keeps the file under My Scripts, beside your kScript (legacy) scripts. Download package hands you <name>-<version>.tgz once the Indicator compiles cleanly: the built module, its sheet (wrun/metadata.json, derived from your declarations), and the manifest (om-package.json), exactly what the registry and the om CLI consume. Publish opens the registry in a popup: a package name (/name), a version (MAJOR.MINOR.PATCH), a visibility, a description. A published version is permanent (bump the version to revise) and it is what unlocks everything beyond the draft: other people add it from the Indicators tab, alerts can watch it (an alert on a draft is refused with "Publish the Indicator before adding an alert"), and om install /name puts it on any machine (functions/publishing.md).

Next

You now know enough to read most Indicators and write simple ones. The next page points you to where to go from here: complete real-world recipes, how the host runs your code, and the references you will reach for most.

Next steps