GGoodAI Docs

Custom Strategy Scripts

Write your own signal logic in TypeScript, backtest it on real market history, and run it as a bot — the same engine everywhere.

Currently in preview. Script access is being rolled out gradually. Scripts are not financial-advice tooling: your code, your trades, your risk.

The model: you write signals, GoodAI trades them

A script is one pure function that looks at recent candles and answers a single question: "buy", "sell", or "hold". Everything with consequences — order placement, position sizing, take-profit and stop-loss, fill handling, retries — is done by GoodAI's signal engine around your script. You cannot place orders directly, and you don't need to.

Because scripts are pure and deterministic, the same code produces the same decisions in a backtest, on paper trading, and live. Backtest first, always: the editor's Run backtest executes your exact saved script against real historical candles with the same fill engine live bots use.

Quick start

  1. Open Scripts in the app sidebar — the editor starts from a working template.
  2. Validate compiles your script and runs it once against test data.
  3. Save version — every save is an immutable version; bots pin the version they were created with, so editing never changes a running bot.
  4. Run backtest — pick venue, pair, candle interval, range and size; results open in the standard backtester view.

The contract

export const signal: SignalScript = (input) => "buy" | "sell" | "hold";

Your function is called once per candle close with:

Field Type Meaning
candles ScriptCandle[] Rolling window of up to 200 OHLCV candles at your chosen interval, oldest → newest. Each has time (epoch ms), open, high, low, close, volume.
position ScriptPosition | null null when flat. When holding: entryPrice, qty, and ageTicks (candles since entry).
params Record<string, unknown> Free-form knobs set per bot/backtest, so one script can run with different thresholds without editing code.

Signal semantics (long-only in v1):

  • "buy" — open a position, if flat and not cooling down. Ignored while holding.
  • "sell" — close the position. Ignored while flat.
  • "hold" — do nothing. Return this while indicators are warming up.

What the engine adds around your script

  • One position at a time, sized by the qty you configure.
  • Take-profit % (optional) — a resting limit sell placed the moment your entry fills; re-placed if the venue drops it.
  • Stop-loss % (optional) — checked every candle and outranks your script: a breach exits even while you say hold.
  • Cooldown (optional) — candles to wait after an exit before the next entry.
  • Self-halt — a script that throws (or whose orders are rejected) three times in a row stops trading and says so on the bot page; fix it, then Stop + Start to resume.

Indicator library

Import from goodai/indicators. Every function returns null until it has enough history — always handle that with hold.

Function Returns Notes
sma(values, period) number | null Simple moving average of the last period values.
ema(values, period) number | null Exponential moving average, seeded with the first period's SMA.
rsi(values, period) number | null Wilder's RSI, 0–100.
atr(candles, period) number | null Average True Range — needs full candles, not closes.
stddev(values, period) number | null Population standard deviation over the window.
bbands(values, period, mult?) {upper, middle, lower} | null Bollinger bands; mult defaults to 2.
highest(values, period) / lowest(values, period) number | null Window extremes — breakouts, channels.
crossover(a, b) / crossunder(a, b) boolean Did series a cross above/below b on the latest step? b may be a series or a constant.

Custom indicators are just code. Map, combine, and weight the arrays however you like — see example 4 below.

Rules of the sandbox

Scripts run in a hardened, deterministic sandbox. Determinism is what makes a backtest an honest preview of live behaviour, so the environment is deliberately strict:

  • Only goodai (types) and goodai/indicators can be imported — no npm packages, no fetch, no filesystem.
  • Date.now(), new Date() and Math.random() are unavailable — candle times are in your input.
  • Budgets per evaluation: CPU and memory are capped; an infinite loop is killed, not waited on.
  • Source up to 64 KB; backtests get a 120-second total budget ("script too slow" means simplify or shorten the range).
  • Backtests run on isolated infrastructure — never on the machines that run live bots.

Examples

1 · Trend following (the starter template)

import { sma } from "goodai/indicators";
import type { SignalScript } from "goodai";

export const signal: SignalScript = ({ candles, position }) => {
  const closes = candles.map((c) => c.close);

  const fast = sma(closes, 12);
  const slow = sma(closes, 48);
  if (fast === null || slow === null) return "hold"; // warming up

  if (!position && fast > slow * 1.001) return "buy"; // trend turned up
  if (position && fast < slow) return "sell"; // trend gone
  return "hold";
};

2 · RSI mean reversion

import { rsi } from "goodai/indicators";
import type { SignalScript } from "goodai";

// Buy oversold, exit overbought. Add a stop-loss % in the bot settings —
// mean reversion without a stop rides losers.
export const signal: SignalScript = ({ candles, position }) => {
  const closes = candles.map((c) => c.close);
  const r = rsi(closes, 14);
  if (r === null) return "hold";

  if (!position && r < 35) return "buy";
  if (position && r > 65) return "sell";
  return "hold";
};

3 · Bollinger band touch

import { bbands } from "goodai/indicators";
import type { SignalScript } from "goodai";

export const signal: SignalScript = ({ candles, position }) => {
  const closes = candles.map((c) => c.close);
  const last = closes[closes.length - 1];
  const b = bbands(closes, 20, 2);
  if (b === null || last === undefined) return "hold";

  if (!position && last < b.lower) return "buy";
  if (position && last > b.middle) return "sell";
  return "hold";
};

4 · Your own indicator, tunable via params

import { ema, rsi, atr } from "goodai/indicators";
import type { SignalScript } from "goodai";

// Combining indicators is plain arithmetic. This builds a custom "quality"
// score from trend strength and volatility, tunable per bot via params.
export const signal: SignalScript = ({ candles, position, params }) => {
  const closes = candles.map((c) => c.close);

  const fast = ema(closes, 12);
  const slow = ema(closes, 26);
  const momentum = rsi(closes, 14);
  const vol = atr(candles, 14);
  const last = closes[closes.length - 1];
  if (fast === null || slow === null || momentum === null || vol === null || last === undefined)
    return "hold";

  const trendPct = ((fast - slow) / slow) * 100; // your own indicator
  const volPct = (vol / last) * 100;
  const minTrend = Number(params.minTrend ?? 0.15);
  const maxVol = Number(params.maxVol ?? 2.5);

  // Every extra condition means FEWER trades — backtest after each change.
  if (!position && trendPct > minTrend && volPct < maxVol && momentum < 65) return "buy";
  if (position && (trendPct < 0 || momentum > 75)) return "sell";
  return "hold";
};

5 · Time-based exits with position.ageTicks

import type { SignalScript } from "goodai";

export const signal: SignalScript = ({ candles, position }) => {
  const closes = candles.map((c) => c.close);
  const last = closes[closes.length - 1];
  if (last === undefined) return "hold";

  if (position) {
    const gainPct = ((last - position.entryPrice) / position.entryPrice) * 100;
    if (gainPct > 1.5) return "sell"; // take the win
    if (position.ageTicks > 48) return "sell"; // two days on 1h candles — move on
    return "hold";
  }
  // ...your entry logic here...
  return "hold";
};

Troubleshooting

  • Backtest completed with zero trades — your entry conditions never fired in that range. Every ANDed condition multiplies selectivity; loosen a threshold, widen the range, or think through whether your conditions can co-occur at all.
  • "script too slow" — the 120-second budget ran out. Avoid O(n²) work per tick, shorten the range, or use 1h candles instead of 1m.
  • Bot shows "paused itself" — three consecutive script errors or order rejections tripped the self-halt. The bot page lists the reason; fix, then Stop + Start.
  • Indicator returns null forever — the window holds at most 200 candles; a 300-period SMA can never warm up.