Examples

Complete agents you can adapt, one per common shape. Each one passes the checker: every block on this page is compiled by the real validator in Obside's test suite. Remember that the short symbol strings ('BTC', 'SPY') are mapped to real listings when you create the agent, not in the code.

Shape Example
Recurring buy Recurring buy (dollar-cost averaging)
Alert once One-shot alert on a level
Indicator cross with stop and target Trend-following trade with a stop and target
Mean reversion with orders RSI mean reversion
Breakout with risk sizing Bollinger breakout sized by risk
Channel breakout with an exit rule Donchian breakout
Higher-timeframe filter Multi-timeframe entry
Tunable parameters SuperTrend with launch variables
Two legs Pairs trade on a z-score
Resting limit orders Grid of limit orders
Dynamic universe Dynamic universe: top 10 by market cap
Calendar Earnings reminder
Existing positions Trailing stop on positions you already hold
Portfolio monitor Portfolio drawdown alert

More patterns live next to their topic: a scheduled rebalance and a weekly buy that starts at launch in Schedules and launch actions, a break-even stop in Orders, trades and risk, news digests, account follows, macro events and an AI-annotated alert in News, data and AI.

Recurring buy (dollar-cost averaging)

Buys a fixed amount every Monday. A contribution buy brings new money in, so it does not set inhibitAutoInvest (see capital and auto-invest). To also buy at launch, use the pattern in Acting at launch.

const symbol = 'BTC';
manager.onPreciseCandle(symbol, () => {}); // subscribe the feed so the schedule can act

manager.cron('0 9 * * 1', () => {
  manager.placeOrder({
    symbol,
    type: 'MARKET',
    position: 'buy',
    amount: 50,
    amountCurrency: 'USD',
  });
}, 'UTC');

One-shot alert on a level

Alerts you the first time an indicator condition is met, then stops itself.

const symbol = 'ETH';
const candles = manager.getIntervalData(symbol, 60);
const rsi = new RSI(new PartialIndicator(candles, 'close'), 14);

manager.onIntervalCandle(symbol, 60, () => {
  if (!rsi.hasValue()) return;
  const value = rsi.getValue();
  if (value === undefined || value > 30) return;
  manager.sendNotification(`ETH RSI is ${value.toFixed(1)} (below 30).`);
  manager.stop();
});

Trend-following trade with a stop and target

Enters on an EMA cross and lets the engine manage the exit through a stop-loss and take-profit. Because there is a defined exit, this uses a trade rather than a plain order. Crossover returns 'firstUp' on the candle where the first series crosses above the second, 'secondUp' on the opposite cross, and null otherwise.

const symbol = 'SPY';
const zone = 'America/New_York';
const candles = manager.getIntervalData(symbol, 1440, zone);
const fast = new EMA(new PartialIndicator(candles, 'close'), 20);
const slow = new EMA(new PartialIndicator(candles, 'close'), 50);
const cross = new Crossover(fast, slow);

manager.onIntervalCandle(symbol, 1440, () => {
  if (!cross.hasValue()) return;
  const signal = cross.getValue();
  const price = manager.getCurrentPrice(symbol);
  if (price === undefined) return;
  if (signal === 'firstUp' && manager.getSymbolPendingTrades(symbol).length === 0) {
    manager.openTrade({
      symbol,
      position: 'buy',
      type: 'MARKET',
      positionSize: { type: 'proportion', proportion: 0.2 },
      stoploss: { type: 'fixed', value: price * 0.95 },
      takeprofit: { type: 'fixed', value: price * 1.15 },
      comment: `EMA20 crossed above EMA50 at ${price.toFixed(2)}`,
    });
  }
  if (signal === 'secondUp') {
    for (const trade of manager.getSymbolPendingTrades(symbol)) {
      manager.closeTrade(trade, { cause: `EMA20 crossed back below EMA50 at ${price.toFixed(2)}` });
    }
  }
}, zone);

RSI mean reversion

Buys a fixed cash amount when the daily RSI is oversold and the agent holds none of its own, and sells what it bought when the RSI is overbought. Orders, not trades: there is no stop or target, only an exit rule. The agent counts its own fills, because the portfolio it runs on can hold QQQ for other reasons (your own shares, another agent's): getPositionQty would see those too.

const symbol = 'QQQ';
const zone = 'America/New_York';
const daily = manager.getIntervalData(symbol, 1440, zone);
const rsi = new RSI(daily.close, 14);

interface OwnBook { ownQty?: number }

manager.onOrderExecuted((order) => {
  if (order.symbol !== symbol || order.filledQty === undefined || !(order.filledQty > 0)) return;
  const book = manager.getStatus<OwnBook>('default');
  const signed = order.position === 'buy' ? order.filledQty : -order.filledQty;
  book.ownQty = Math.max(0, (book.ownQty ?? 0) + signed);
});

manager.onIntervalCandle(symbol, 1440, () => {
  if (!rsi.hasValue()) return;
  const value = rsi.getValue();
  if (value === undefined) return;
  if (manager.getSymbolPendingOrders(symbol).length > 0) return;
  const owned = manager.getStatus<OwnBook>('default').ownQty ?? 0;
  if (value < 30 && owned === 0) {
    manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: 1000, amountCurrency: 'USD' });
  } else if (value > 70 && owned > 0) {
    manager.placeOrder({ symbol, type: 'MARKET', position: 'sell', qty: Math.min(owned, manager.getPositionQty(symbol)), inhibitAutoInvest: true });
  }
}, zone);

Bollinger breakout sized by risk

Opens a long when a 4-hour candle closes above the upper band, with the stop and target set from the ATR, risking 1% of the wallet at the stop.

const symbol = 'ETH';
const candles = manager.getIntervalData(symbol, 240);
const bands = new Bollinger(candles.close, 20, 2);
const atr = new RMA(new TR(candles), 14);

manager.onIntervalCandle(symbol, 240, () => {
  if (!bands.hasValue() || !atr.hasValue() || !candles.hasValue()) return;
  const band = bands.getValue();
  const atrValue = atr.getValue();
  const close = candles.getValue().close;
  if (band.upper === undefined || atrValue === undefined) return;
  if (close <= band.upper || manager.getSymbolPendingTrades(symbol).length > 0) return;
  manager.openTrade({
    symbol,
    position: 'buy',
    type: 'MARKET',
    positionSize: { type: 'risk', risk: 0.01 },
    stoploss: { type: 'fixed', value: close - 2 * atrValue },
    takeprofit: { type: 'fixed', value: close + 4 * atrValue },
    comment: `Close ${close.toFixed(2)} above the upper band ${band.upper.toFixed(2)}, ATR ${atrValue.toFixed(2)}`,
  });
});

Donchian breakout

Enters when the close breaks the highest high of the previous 20 days and exits when it breaks the lowest low of the previous 10. The windows exclude the current candle.

const symbol = 'GLD';
const zone = 'America/New_York';
const daily = manager.getIntervalData(symbol, 1440, zone);
const last21 = new History(daily, 21);

manager.onIntervalCandle(symbol, 1440, () => {
  if (!last21.hasValue()) return;
  const candles = last21.getValue();
  if (candles.length < 21) return;
  const current = candles[candles.length - 1];
  if (current === undefined) return;
  const previous = candles.slice(0, -1);
  const high20 = Math.max(...previous.map((c) => c.high));
  const low10 = Math.min(...previous.slice(-10).map((c) => c.low));
  const open = manager.getSymbolPendingTrades(symbol);
  if (open.length === 0 && current.close > high20) {
    manager.openTrade({
      symbol,
      position: 'buy',
      type: 'MARKET',
      positionSize: { type: 'proportion', proportion: 0.25 },
      comment: `Close ${current.close.toFixed(2)} above the 20-day high ${high20.toFixed(2)}`,
    });
  } else if (current.close < low10) {
    for (const trade of open) {
      manager.closeTrade(trade, { cause: `Close ${current.close.toFixed(2)} below the 10-day low ${low10.toFixed(2)}` });
    }
  }
}, zone);

Multi-timeframe entry

Takes longs only while the daily close is above the daily 200 EMA, entering when the hourly RSI crosses up through 30. The stop sits at the lowest low of the last 10 hourly candles and the target at twice the risk.

const symbol = 'BTC';
const daily = manager.getIntervalData(symbol, 1440, 'UTC');
const hourly = manager.getIntervalData(symbol, 60);
const trend = new EMA(daily.close, 200);
const rsi = new RSI(hourly.close, 14);
const rsiHistory = new History(rsi, 2);
const lastTen = new History(hourly, 10);

manager.onIntervalCandle(symbol, 60, () => {
  if (!daily.hasValue() || !trend.hasValue() || !rsiHistory.hasValue() || !lastTen.hasValue() || !hourly.hasValue()) return;
  const ema200 = trend.getValue();
  const [previousRsi, currentRsi] = rsiHistory.getValue();
  const candles = lastTen.getValue();
  if (ema200 === undefined || previousRsi === undefined || currentRsi === undefined || candles.length < 10) return;
  if (daily.getValue().close <= ema200) return; // no longs below the daily trend
  if (!(previousRsi < 30 && currentRsi >= 30)) return;
  if (manager.getSymbolPendingTrades(symbol).length > 0) return;
  const entry = hourly.getValue().close;
  const stop = Math.min(...candles.map((c) => c.low));
  if (stop >= entry) return;
  manager.openTrade({
    symbol,
    position: 'buy',
    type: 'MARKET',
    positionSize: { type: 'risk', risk: 0.01 },
    stoploss: { type: 'fixed', value: stop },
    takeprofit: { type: 'fixed', value: entry + 2 * (entry - stop) },
    comment: `RSI crossed up through 30 (${currentRsi.toFixed(1)}) above the daily EMA200 ${ema200.toFixed(0)}`,
  });
});

SuperTrend with launch variables

Long-only SuperTrend on 4-hour candles, whose ATR period and multiplier the user can change when starting a run. A flip is detected by comparing the last two values.

const symbol = 'QQQ';
const period = manager.getVariable('atrPeriod', 10);
const multiplier = manager.getVariable('multiplier', 3);
const candles = manager.getIntervalData(symbol, 240);
const supertrend = new SuperTrend(candles, period, multiplier);
const recent = new History(supertrend, 2);

manager.onIntervalCandle(symbol, 240, () => {
  if (!recent.hasValue()) return;
  const [previous, current] = recent.getValue();
  if (previous === undefined || current === undefined) return;
  const open = manager.getSymbolPendingTrades(symbol);
  if (!previous.up && current.up && open.length === 0) {
    manager.openTrade({
      symbol,
      position: 'buy',
      type: 'MARKET',
      positionSize: { type: 'proportion', proportion: 0.5 },
      comment: `SuperTrend(${period}, ${multiplier}) turned bullish at ${current.close.toFixed(2)}`,
    });
  } else if (previous.up && current.down) {
    for (const trade of open) {
      manager.closeTrade(trade, { cause: `SuperTrend turned bearish at ${current.close.toFixed(2)}` });
    }
  }
});

Pairs trade on a z-score

Trades the spread between two related stocks: short the rich leg and buy the cheap one when the 60-day z-score of the log price ratio passes 2, and close both legs when it falls back under 0.5. Each leg is a trade, so the exit closes exactly what the agent opened, whatever else the portfolio holds of KO or PEP. The ratio is a DerivedIndicator over both daily series, so it is part of the indicator graph: it is rebuilt from history at every start-up like any indicator, and updates once both legs have closed.

const legA = 'KO';
const legB = 'PEP';
const zone = 'America/New_York';
const LOOKBACK = 60;
const LEG_USD = 5000;
const a = manager.getIntervalData(legA, 1440, zone);
const b = manager.getIntervalData(legB, 1440, zone);
const ratio = new DerivedIndicator(() => {
  const candleA = a.getValue();
  const candleB = b.getValue();
  return candleA === undefined || candleB === undefined ? undefined : Math.log(candleA.close / candleB.close);
}, [a, b]);
const ratios = new History(ratio, LOOKBACK);

interface PairState { side?: 'longA' | 'shortA' }

manager.onIntervalCandle(legA, 1440, () => {
  if (!ratios.hasValue()) return;
  const state = manager.getStatus<PairState>('default');
  const values = ratios.getValue().filter((v): v is number => v !== undefined);
  if (values.length < LOOKBACK) return;
  const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
  const sd = Math.sqrt(values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length);
  const last = values[values.length - 1];
  if (sd === 0 || last === undefined) return;
  const z = (last - mean) / sd;

  if (state.side !== undefined && Math.abs(z) < 0.5) {
    for (const trade of [...manager.getSymbolPendingTrades(legA), ...manager.getSymbolPendingTrades(legB)]) {
      manager.closeTrade(trade, { cause: `z-score ${z.toFixed(2)} back under 0.5` });
    }
    state.side = undefined;
    return;
  }
  const size = { type: 'amount' as const, amount: LEG_USD };
  if (state.side === undefined && z > 2) {
    manager.openTrade({ symbol: legA, type: 'MARKET', position: 'sell', positionSize: size, comment: `z-score ${z.toFixed(2)}: ${legA} rich` });
    manager.openTrade({ symbol: legB, type: 'MARKET', position: 'buy', positionSize: size, comment: `z-score ${z.toFixed(2)}: ${legB} cheap` });
    state.side = 'shortA';
  } else if (state.side === undefined && z < -2) {
    manager.openTrade({ symbol: legA, type: 'MARKET', position: 'buy', positionSize: size, comment: `z-score ${z.toFixed(2)}: ${legA} cheap` });
    manager.openTrade({ symbol: legB, type: 'MARKET', position: 'sell', positionSize: size, comment: `z-score ${z.toFixed(2)}: ${legB} rich` });
    state.side = 'longA';
  }
}, zone);

Shorting works on paper and in backtests; live, it depends on your broker.

Grid of limit orders

Keeps five resting limit buys spaced 1% apart under the price, and places a limit sell 1% above each filled buy. onOrderExecuted reports this agent's fills; the price of a fill is order.price. It runs on paper (backtest and demo) only: a live broker skips LIMIT orders for now.

const symbol = 'BTC';
const LEVELS = 5;
const STEP = 0.01;
const QTY = 0.001;
manager.onPreciseCandle(symbol, () => {});

manager.onOrderExecuted((order) => {
  if (order.symbol !== symbol || order.position !== 'buy' || order.type !== 'LIMIT') return;
  const fill = order.price ?? order.limitPrice;
  if (fill === undefined) return;
  manager.placeOrder({ symbol, type: 'LIMIT', position: 'sell', qty: order.filledQty ?? QTY, limitPrice: fill * (1 + STEP), inhibitAutoInvest: true });
});

manager.cron('0 * * * *', () => {
  const price = manager.getCurrentPrice(symbol);
  if (price === undefined) return;
  for (const order of manager.getSymbolPendingOrders(symbol)) {
    if (order.position === 'buy') manager.cancelOrder(order); // re-centre the buy ladder every hour
  }
  for (let level = 1; level <= LEVELS; level++) {
    manager.placeOrder({ symbol, type: 'LIMIT', position: 'buy', qty: QTY, limitPrice: price * (1 - STEP * level) });
  }
});

Dynamic universe: top 10 by market cap

Holds the ten largest S&P 500 companies, equal weight, rebalanced on the first weekday of each month (names that left the top 10 are sold, names above their weight are trimmed, deficits are bought), with a lump sum invested once at launch. No asset is declared: the universe comes from the ranking. Orders are cash-sized because newly ranked names have no price yet.

const zone = 'America/New_York';
const LUMP_SUM = 10000;
interface TopState { lastMonth?: string; funded?: boolean }

function marketOpen(nowMs: number): boolean {
  const ny = DateTime.fromMillis(nowMs).setZone(zone);
  const minutes = ny.hour * 60 + ny.minute;
  return ny.weekday <= 5 && minutes >= 9 * 60 + 31 && minutes < 15 * 60 + 55;
}

async function runMonth(): Promise<void> {
  const state = manager.getStatus<TopState>('default');
  const month = DateTime.fromMillis(manager.getCurrentTime()).setZone(zone).toFormat('yyyy-LL');
  if (state.lastMonth === month) return;
  const previous = state.lastMonth;
  state.lastMonth = month; // claimed before the first await

  let ranked: PieuvreRankedEntity[];
  try {
    ranked = await manager.getPieuvreRank({ metric: 'market_cap', universe: 'sp500', order: 'desc', limit: 10 });
  } catch (error) {
    manager.handleError(error);
    state.lastMonth = previous; // data unavailable, nothing done: retry next minute
    return;
  }
  const targets: string[] = [];
  for (const entry of ranked) {
    const key = entry.snaptradeSymbol ?? entry.backtestSymbol;
    if (key !== null) targets.push(key);
  }
  if (targets.length === 0) {
    state.lastMonth = previous;
    return;
  }
  if (state.funded !== true) {
    manager.increaseCapital(LUMP_SUM); // once in the agent's life
    state.funded = true;
  }

  // Value the book and set the per-name target BEFORE any sell. Free cash is read once, here:
  // paper sells settle at once, so reading it after them would count their proceeds twice.
  const cash = manager.getAvailableCapital();
  const keep = new Set(targets);
  let bookValue = 0;
  for (const position of manager.getPositions()) {
    const price = manager.getCurrentPrice(position.symbol);
    if (position.qty > 0 && price !== undefined) bookValue += position.qty * price;
  }
  const perName = ((bookValue + cash) * 0.97) / targets.length; // 3% headroom for fees and drift

  // Sell what left the top 10, and trim names kept above their target, counting the proceeds
  // next to each sell.
  const held = new Map<string, number>();
  let proceeds = 0;
  for (const position of manager.getPositions()) {
    const price = manager.getCurrentPrice(position.symbol);
    if (position.qty <= 0 || price === undefined) continue;
    const value = position.qty * price;
    if (!keep.has(position.symbol)) {
      manager.placeOrder({ symbol: position.symbol, type: 'MARKET', position: 'sell', qty: position.qty, inhibitAutoInvest: true });
      proceeds += position.qty * price;
    } else if (value > perName * 1.02) {
      const sellQty = (value - perName) / price;
      manager.placeOrder({ symbol: position.symbol, type: 'MARKET', position: 'sell', qty: sellQty, inhibitAutoInvest: true });
      proceeds += sellQty * price;
      held.set(position.symbol, perName);
    } else {
      held.set(position.symbol, value);
    }
  }

  // Buy each target's deficit within one shared cash budget.
  let remaining = Math.max(0, cash) + proceeds;
  for (const key of targets) {
    const deficit = perName - (held.get(key) ?? 0);
    const spend = Math.min(deficit, remaining);
    if (spend <= 1) continue;
    manager.placeOrder({ symbol: key, type: 'MARKET', position: 'buy', amount: spend, amountCurrency: 'USD', inhibitAutoInvest: true });
    remaining -= spend;
  }
}

manager.cron('* * * * *', async () => {
  if (!marketOpen(manager.getCurrentTime())) return;
  await runMonth();
});

The single per-minute trigger both invests at launch and runs each month's first open session; the month key limits it to one cycle a month. It backtests point in time (the ranking is the one known on each simulated date), but a per-minute schedule keeps backtests to about a year: for longer ones, replace it with a daily '31 9 * * 1-5' schedule in New York time.

Earnings reminder

Each weekday morning, lists which companies of a watchlist report in the next 24 hours, with the consensus EPS. The calendar is narrowed to the watchlist so the answer is never truncated.

const zone = 'America/New_York';
const WATCHLIST = ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'GOOGL'];

manager.cron('0 8 * * 1-5', async () => {
  let events: PieuvreEvent[];
  try {
    events = await manager.getPieuvreUpcoming({ withinMs: 24 * 60 * 60 * 1000, eventTypes: ['earnings'], tickers: WATCHLIST });
  } catch (error) {
    manager.handleError(error);
    return;
  }
  const lines = events.map((event) => {
    const when = DateTime.fromMillis(event.timestamp).setZone(zone).toFormat('ccc HH:mm');
    const eps = event.value === undefined ? 'no consensus' : `consensus EPS **${event.value.toFixed(2)}**`;
    return `**${event.ticker ?? event.summary}**: ${when} New York, ${eps}`;
  });
  if (lines.length > 0) manager.sendNotification(`Earnings in the next 24 hours:\n\n${lines.join('\n')}`);
}, zone);

Trailing stop on positions you already hold

Watches the whole account book and trails a protective stop under each position, without declaring any asset. It exits with closePosition, the safe way to reduce a position the agent did not open.

interface Guard { high: number }
interface State { guards?: Record<string, Guard> }
const TRAIL = 0.05; // 5%

manager.cron('* * * * *', () => {
  const state = manager.getStatus<State>('default');
  const guards = state.guards ?? {};
  const held = new Set<string>();

  for (const position of manager.getPositions()) {
    if (position.qty <= 0) continue; // longs only in this example
    held.add(position.symbol);
    const price = manager.getCurrentPrice(position.symbol);
    if (price === undefined || price <= 0) continue;

    const guard = guards[position.symbol];
    const high = guard === undefined ? price : Math.max(guard.high, price);
    guards[position.symbol] = { high };

    if (price <= high * (1 - TRAIL)) {
      const pending = manager.getSymbolPendingOrders(position.symbol);
      if (!pending.some(order => order.position === 'sell')) {
        manager.closePosition(position.symbol);
      }
    }
  }

  for (const symbol of Object.keys(guards)) {
    if (!held.has(symbol)) delete guards[symbol];
  }
  state.guards = guards;
});

This one runs live or in a paper portfolio that holds positions; it is not backtestable, because a backtest starts from an empty wallet with nothing to trail.

Portfolio drawdown alert

Notifies once when the portfolio's value falls 10% below its highest value since the agent started, and again only after it recovers above that line and falls again.

interface DrawdownState { peak?: number; alerted?: boolean }
const LIMIT = 0.10;

manager.cron('*/5 * * * *', () => {
  const value = manager.getLatentCapital();
  if (!Number.isFinite(value) || value <= 0) return;
  const state = manager.getStatus<DrawdownState>('default');
  const peak = Math.max(state.peak ?? value, value);
  state.peak = peak;
  const drawdown = 1 - value / peak;
  if (drawdown >= LIMIT && state.alerted !== true) {
    state.alerted = true;
    manager.sendNotification(`Your portfolio is **${(drawdown * 100).toFixed(1)}%** below its high since monitoring began (**${value.toFixed(0)}** vs ${peak.toFixed(0)}).`);
  } else if (drawdown < LIMIT && state.alerted === true) {
    state.alerted = false; // re-arm once it recovers above the line
  }
});

The high is the peak observed since the agent started, not the portfolio's all-time high. Like the trailing stop, it runs against a real or demo portfolio, not in a backtest.

Browse the documentation