Pine signals to SJS orders
Consume recent numeric Pine events once, with order and position checks.
Prerequisites
Use one instrument, one-minute historical bars, at most one long position, and no other order-producing source. Paste the complete EMA Pine indicator into Pine; keep its hidden numeric plot named pine.signal.
First run the signal observer to confirm numeric points and timing. This tutorial replaces the earlier Buy/Sell chart-arrow reader with that explicit raw plot contract.
Add the complete SJS source
SJS runs before Pine in the shared frame, so a new Pine event is normally consumed on a later SJS invocation. This reader accepts the latest nonzero event within two minutes of its opening timestamp and consumes it once.
export async function app({ context, bar, log }) {
const frame = bar?.date == null ? NaN : new Date(bar.date).getTime();
if (!Number.isFinite(frame)) return "waiting: invalid frame";
const points = context.pineState?.plots?.["pine.signal"]?.data;
if (!Array.isArray(points)) return "waiting: Pine output";
const state = (context.sjsState ??= {}).tutorial ??= {};
if (state.pending) return "waiting: busy";
const signal = points.slice(-200).filter(point =>
typeof point?.time === "number" && Number.isFinite(point.time)
&& (point.value === 1 || point.value === -1)
&& point.time <= frame && frame - point.time <= 120000
).reduce((latest, point) => !latest || point.time >= latest.time ? point : latest, null);
if (!signal || (state.lastSignalTime != null && signal.time <= state.lastSignalTime)) return "hold: no new event";
state.lastSignalTime = signal.time;
const side = signal.value === 1 ? "BUY" : "SELL";
state.signal = { frame, signalTime: signal.time, side, close: bar.close };
state.pending = true;
try {
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 > 1) return "waiting: unresolved exposure";
const position = positions[0];
if (position && position.action !== "BUY") return "hold: not long";
if (side === "BUY" && position) return "hold: already long";
if (side === "SELL" && !position) return "hold: already flat";
state.lastAttempt = { frame, signalTime: signal.time, side, outcome: "pending" };
const options = { source: "sjs", metadata: { strategy: "pine-tutorial", signal: state.signal } };
const result = side === "BUY"
? await context.placeOrder({
id: `pine-entry-${signal.time}`, instrument: context.instrument,
action: "BUY", quantity: 1, type: "MARKET",
}, options)
: await context.closePositions(position, options);
state.lastAttempt.outcome = result !== false && result != null ? "accepted" : "not-accepted";
log?.("pine-tutorial", state.lastAttempt);
return state.lastAttempt.outcome;
} catch (error) {
state.lastError = String(error);
if (state.lastAttempt?.frame === frame) state.lastAttempt.outcome = "error";
log?.("pine-tutorial-error", state.lastError);
return "error";
} finally {
state.pending = false;
}
}Verify decisions and outcomes
A recent 1 while flat requests a one-unit BUY. A recent -1 while holding a long requests a close. BUY while long does not add; SELL while flat does not open a short. Pending orders, multiple positions, or a short position block the example's execution.
Inspect tutorial.signal, lastAttempt, and logs. Keep signal time and consuming-frame time distinct. In a fixture, a signal timestamped 09:30 can be consumed at 09:31; a repeated 09:30 event must not create another request.
A later zero does not cancel an unconsumed nonzero event inside the freshness window. Same-timestamp revisions do not create new events once consumed. Future points and events older than two minutes are rejected.
Understand consumed events
The event is consumed before snapshot reads and submission. Rejected requests, thrown errors, or pending-order blocks do not cause automatic retries of that event. These guards live in memory and do not establish broker-wide idempotency.
The two-minute window assumes short-interval data. Revisit it for another interval, and remember that historical confirmation differs from an open live candle. A signal produced on the final replay frame might have no later SJS invocation to consume it.
Next: experiment with Agent Lab, or add L3 oversight.