Build your first SJS strategy
Run a complete crossover and trace its first entry and exit.
Prerequisites
Use a fresh historical app or Agent Lab experiment, one instrument, one-minute bars, at most one position, and no other execution source. This long-only example uses a three-observation mean and resets after gaps longer than two minutes. It is a behavior walkthrough, not a performance claim.
Add the complete source
Paste into SJS and save. The first valid observation at each increasing timestamp is used; this does not detect live candle closure.
const settings = { period: 3, quantity: 1, maximumGapMs: 120000 };
export async function app({ context, bar, log, skipall }) {
if (bar?.date == null || typeof bar.close !== "number"
|| !Number.isFinite(bar.close)) return "waiting: invalid bar";
const frame = new Date(bar.date).getTime();
if (!Number.isFinite(frame)) return "waiting: invalid timestamp";
const root = context.sjsState ??= {};
const state = root.crossoverTutorial ??= { closes: [] };
if (state.busy) return "waiting: evaluation in progress";
if (state.lastFrame != null && frame <= state.lastFrame) {
return "ignored: repeated or older timestamp";
}
if (state.lastFrame != null && frame - state.lastFrame > settings.maximumGapMs) {
state.closes = [];
state.previousRelation = undefined;
}
state.lastFrame = frame;
state.signal = undefined;
state.closes = [...state.closes, bar.close].slice(-settings.period);
if (state.closes.length < settings.period) return "waiting: warmup";
const average = state.closes.reduce((sum, close) => sum + close, 0) / settings.period;
const relation = Math.sign(bar.close - average);
const previousRelation = state.previousRelation;
state.previousRelation = relation;
const side = previousRelation != null && previousRelation <= 0 && relation > 0
? "BUY"
: previousRelation != null && previousRelation >= 0 && relation < 0
? "EXIT"
: "HOLD";
state.signal = { frame, side, close: bar.close, average };
if (side === "HOLD") return "hold: no crossover";
state.busy = true;
try {
if (typeof context.getOrders !== "function") {
return "waiting: order inspection unavailable";
}
const orders = await context.getOrders();
const positions = await context.getPositions();
if (!Array.isArray(orders) || !Array.isArray(positions)) {
return "waiting: invalid execution snapshot";
}
if (orders.length) return "waiting: existing orders";
if (positions.length > 1) return "waiting: multiple positions";
const position = positions[0];
if (position && position.action !== "BUY") return "hold: position is not long";
if (side === "BUY" && position) return "hold: already long";
if (side === "EXIT" && !position) return "hold: already flat";
const metadata = { strategy: "crossover-tutorial", signal: state.signal };
state.lastAttempt = { frame, side, outcome: "pending" };
const result = side === "BUY"
? await context.placeOrder({
id: `crossover-entry-${frame}`,
instrument: context.instrument,
action: "BUY",
quantity: settings.quantity,
type: "MARKET",
}, { source: "sjs", metadata })
: await context.closePositions(position, { source: "sjs", metadata });
const accepted = result !== false && result !== undefined && result !== null;
state.lastAttempt.outcome = accepted ? "accepted" : "not-accepted";
log?.("sjs-crossover", { ...state.signal, ...state.lastAttempt });
if (accepted) skipall();
return accepted ? "execution: accepted" : "execution: not accepted";
} catch (error) {
state.lastError = String(error);
if (state.lastAttempt?.frame === frame) state.lastAttempt.outcome = "error";
log?.("sjs-crossover-error", { frame, error: state.lastError });
return "execution: error";
} finally {
state.busy = false;
}
}Run a known sequence
Choose a short historical window with direction changes. For a deterministic reasoning fixture, closes 100, 100, 100, 103, 99 at one-minute intervals establish a mean on observation three, produce BUY on four, and EXIT on five.
The EXIT request requires an actual long position and no pending orders. An accepted entry alone does not prove those conditions are satisfied.
Verify
Inspect crossoverTutorial.signal, lastAttempt, and matching logs. Compare the requested side and quantity with orders, positions, and trades. Repeating a timestamp should not submit again. A rejected or blocked crossover is consumed; staying above the mean does not retry it.
Common mistakes are changing the interval without revisiting the gap limit, expecting the first initialized mean to enter immediately, or enabling a second order source. The complete behavior is explained in SJS getting started.
Next: indicator-driven entry.