Practical tutorials

Build an indicator-driven entry

Replace a local calculation with a registered SMA while keeping explicit entry guards.

Prerequisites

Use one instrument, one-minute historical input, no other order source, and initially no position. This entry-only example demonstrates a registered calculation; it does not supply an exit plan. Inspect it in a short simulated experiment.

Add the complete source

The script observes the relationship between current close and cal("sma", { minutes: 3 }). It requests one unit only after that relationship crosses upward and orders/positions are empty.

export async function app({ context, bar, cal, log }) {
  const frame = bar?.date == null ? NaN : new Date(bar.date).getTime();
  if (!Number.isFinite(frame) || typeof bar.close !== "number"
    || !Number.isFinite(bar.close)) return "waiting: invalid bar";
  const state = (context.sjsState ??= {}).indicatorEntry ??= {};
  if (state.busy) return "waiting: busy";
  if (state.lastFrame != null && frame <= state.lastFrame) return "ignored: old frame";
  if (state.lastFrame != null && frame - state.lastFrame > 120000) state.relation = undefined;
  state.lastFrame = frame;
  state.busy = true;
  try {
    const average = await cal("sma", { minutes: 3 });
    if (typeof average !== "number" || !Number.isFinite(average)) {
      state.relation = undefined;
      return "waiting: SMA unavailable";
    }
    const previous = state.relation;
    state.relation = Math.sign(bar.close - average);
    if (!(previous != null && previous <= 0 && state.relation > 0)) return "hold: no upward crossing";
    state.signal = { frame, close: bar.close, average };
    if (typeof context.getOrders !== "function") return "waiting: orders unavailable";
    const orders = await context.getOrders();
    const positions = await context.getPositions();
    if (!Array.isArray(orders) || !Array.isArray(positions)) return "waiting: invalid snapshot";
    if (orders.length || positions.length) return "waiting: exposure exists";
    state.lastAttempt = { frame, outcome: "pending" };
    const result = await context.placeOrder({
      id: `indicator-entry-${frame}`, instrument: context.instrument,
      action: "BUY", quantity: 1, type: "MARKET",
    }, { source: "sjs", metadata: { strategy: "indicator-entry", signal: state.signal } });
    state.lastAttempt.outcome = result !== false && result != null ? "accepted" : "not-accepted";
    log?.("indicator-entry", state.lastAttempt);
    return state.lastAttempt.outcome;
  } catch (error) {
    state.relation = undefined;
    state.lastError = String(error);
    if (state.lastAttempt?.frame === frame) state.lastAttempt.outcome = "error";
    log?.("indicator-entry-error", state.lastError);
    return "error";
  } finally {
    state.busy = false;
  }
}

Verify the result

With calculator values fixed at 100, closes 99, 101 on increasing frames should produce one entry attempt. The first observation only establishes the relation. A missing, string, or non-finite calculation clears the relation; the next valid value establishes it again.

Check indicatorEntry.signal and lastAttempt, then the actual order and position. A pending order or any existing position prevents entry. A blocked crossing is consumed, not retried merely because price stays above the average.

Understand the data change

This SMA requests a historical time window and averages returned closes. It is not guaranteed to equal the first tutorial's last three observed closes or to contain exactly three bars. A finite value does not prove complete history; verify coverage before making readiness part of a decision.

Common mistakes are treating undefined as zero, calling every above-average frame a crossover, and assuming this entry-only walkthrough manages a position afterward.

Next: prevent duplicate orders.

On this page