Build a stateful exit observer and request
Track a long position high-water mark and request one close after a fixed drawdown.
Prerequisites
Use a controlled simulated fixture with one existing long position, a stable position ID, entry timestamp, entry price, and one instrument. This source manages exits only. It neither creates the starting position nor adds entries; if your experiment cannot seed one, use an isolated test harness as the guide tests do.
The example tracks observed closes and requests a close after a drawdown of two raw price units from the higher of entry price and observed closes. It is not a broker-native protective stop and does not observe unseen intrabar highs.
Add the complete source
export async function app({ context, bar, 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 ??= {}).exitTrail ??= {};
if (state.busy) return "waiting: busy";
if (state.lastFrame != null && frame <= state.lastFrame) return "ignored: old frame";
state.lastFrame = frame;
state.busy = true;
try {
if (typeof context.getOrders !== "function") return "waiting: orders unavailable";
const positions = await context.getPositions();
const orders = await context.getOrders();
if (!Array.isArray(positions) || !Array.isArray(orders)) return "waiting: invalid snapshot";
if (!positions.length) {
state.position = undefined;
return "hold: flat";
}
if (positions.length !== 1) return "waiting: multiple positions";
const position = positions[0];
const entryTime = position.entryDate == null ? NaN : new Date(position.entryDate).getTime();
if (position.action !== "BUY" || !position.id || !Number.isFinite(entryTime)
|| typeof position.price !== "number" || !Number.isFinite(position.price)) {
return "waiting: unsupported position";
}
const key = `${position.id}:${entryTime}:${position.price}`;
if (state.position?.key !== key) {
state.position = { key, high: Math.max(position.price, bar.close), attempted: false };
}
const tracked = state.position;
tracked.high = Math.max(tracked.high, bar.close);
const exitPrice = tracked.high - 2;
state.evidence = { frame, key, high: tracked.high, close: bar.close, exitPrice };
if (bar.close > exitPrice) return "hold: below drawdown threshold";
if (orders.length) return "waiting: pending order";
if (tracked.attempted) return "hold: exit already attempted";
tracked.attempted = true;
tracked.outcome = "pending";
const result = await context.closePositions(position, {
source: "sjs", metadata: { strategy: "exit-trail", evidence: state.evidence },
});
tracked.outcome = result !== false && result != null ? "accepted" : "not-accepted";
log?.("exit-trail", { ...state.evidence, outcome: tracked.outcome });
return tracked.outcome;
} catch (error) {
state.lastError = String(error);
if (state.position?.outcome === "pending") state.position.outcome = "error";
log?.("exit-trail-error", state.lastError);
return "error";
} finally {
state.busy = false;
}
}Verify state across frames
With entry price 100, observed closes 101, 104, 102 move the high-water mark to 104 and request a close at 102. The exact threshold is included. Confirm the actual position snapshot passed to close, not a reconstructed quantity.
A pending order delays the close request while the watermark continues updating. A flat snapshot clears the tracked position. A changed ID, entry timestamp, or entry price establishes a new tracking identity; this can reset the watermark after changes in position accounting.
Inspect failures deliberately
The example records one close attempt per identity, even when the close rejects or throws. It intentionally does not retry. After an unsuccessful attempt, exposure can remain unmanaged by this script; production retry/reconciliation behavior is a separate design requirement.
Common mistakes are interpreting two units as two exchange ticks, relying on process memory after a restart, assuming a close acceptance means flat, or attaching the source to an unrelated short/multiple-position account.
Next: Pine signals to SJS orders.