Strategy Authoring Guide
This guide is the contract between the AI chat and the bot runner. Any
strategy code persisted via update_strategy_code MUST follow it — the
handler now runs validate_strategy_code against the candidate before
inserting a new bot_strategies version, and rejects malformed input.
TL;DR
For bundled strategies, start with SOUL.md. It is the user-facing contract
for the bot: purpose, algorithm, bias, entry, exit, utilities, indicators,
step-by-step behavior, required checks, and expected metrics. Code changes
must implement that contract. If a code change alters the contract, update
SOUL.md in the same strategy version.
export default function strategy(input) {
const history = input.history;
const price = history[history.length - 1].close;
const ema50 = Q.ema(history, 50);
const rsi = Q.rsi(history, 14);
if (ema50 === null || rsi === null) return { action: 'HOLD', reason: 'Warming up', checks: [
{ name: 'warmup', label: 'Indicators ready', passed: false },
]};
const buyOk = rsi < 40 && price < ema50;
const sellOk = rsi > 60 && price > ema50;
const checks = [
// Shared — no `decision` field
{ name: 'ema_ready', label: 'EMA(50) computed', passed: true, value: ema50 },
// BUY checks — decision: 'BUY'
{ name: 'rsi_oversold', label: 'RSI(14) < 40 (oversold)', passed: rsi < 40, value: rsi, decision: 'BUY' },
{ name: 'price_below_ema', label: 'Price below EMA(50)', passed: price < ema50, value: price, decision: 'BUY' },
// SELL checks — decision: 'SELL'
{ name: 'rsi_overbought', label: 'RSI(14) > 60 (overbought)', passed: rsi > 60, value: rsi, decision: 'SELL' },
{ name: 'price_above_ema', label: 'Price above EMA(50)', passed: price > ema50, value: price, decision: 'SELL' },
];
if (buyOk) return { action: 'BUY', reason: `RSI ${rsi.toFixed(1)} oversold, price below EMA`, checks };
if (sellOk) return { action: 'SELL', reason: `RSI ${rsi.toFixed(1)} overbought, price above EMA`, checks };
return { action: 'HOLD', reason: 'No confluence', checks };
}
Complete Input Reference
Top-level: input
| Field | Type | Description |
|---|---|---|
input.candle | object | The current (most-recently-closed) candle. Identical to input.history[input.history.length - 1]. |
input.history | object[] | All candles available to the strategy, oldest first. The last element is the current candle. |
input.mtfHistory | object | undefined | Multi-timeframe candle histories keyed by timeframe string. Only present when the strategy declares MTF parameters. |
input.portfolio | object | Account state at evaluation time. |
input.parameters | object | User-configured bot parameters. All keys are unknown — always coerce with type guards. |
input.context | object | Runtime metadata about the current evaluation. |
input.execution | object | System-injected context. Always present. Cannot be removed or overridden by parameters. |
input.candle — current candle
Identical to the last element of input.history. Use input.history for indicator calculations.
| Field | Type | Description |
|---|---|---|
candle.time | string | ISO 8601 timestamp of the candle's open time (e.g. "2024-03-15T10:00:00.000Z"). |
candle.open | number | Opening price of the candle. |
candle.high | number | Highest price reached during the candle. |
candle.low | number | Lowest price reached during the candle. |
candle.close | number | Closing price. Use this as the current market price. |
candle.volume | number | Volume traded during the candle. May be 0 for some data providers. |
input.history[] — candle array
Array of candles in ascending chronological order (oldest first). The last element (history[history.length - 1]) is the current candle. Each element has the same shape as input.candle.
Correct access patterns:
const price = input.history[input.history.length - 1].close; // current price
const prevClose = input.history[input.history.length - 2]?.close; // previous candle close
const ema50 = Q.ema(input.history, 50); // current EMA value
const emaPrev = Q.ema(input.history.slice(0, -1), 50); // previous EMA value
Never use input.candle for indicator calculations — always pass input.history to Q.* functions.
input.mtfHistory — multi-timeframe histories
Present only when the strategy declares MTF parameters (mtf_filter.bias_timeframe, timeframe_pairs, etc.). Keys are timeframe strings.
const h4Candles = input.mtfHistory?.['4h'] ?? [];
const m15Candles = input.mtfHistory?.['15m'] ?? [];
const h4Bias = Q.ema(h4Candles, 50);
Each array has the same candle shape as input.history, oldest first.
input.portfolio — account state
| Field | Type | Description |
|---|---|---|
portfolio.cash | number | Available cash in account currency. In live mode: the bot's current balance. In backtest: running balance after closed trades. |
portfolio.equity | number | Total equity = cash + unrealized P&L of any open position. |
portfolio.openPosition | object | null | The current open position, or null when flat. Always check for null before accessing sub-fields. |
portfolio.lastTradeAt | string | null | ISO timestamp of when the most recent trade was closed. null if no trades have been completed yet. |
portfolio.openPosition — open position (when not null)
| Field | Type | Description |
|---|---|---|
openPosition.type | 'BUY' | 'SELL' | Direction of the position: 'BUY' = long, 'SELL' = short. |
openPosition.entryPrice | number | The fill price at which the position was opened. |
openPosition.entryTime | string | ISO timestamp of when the position was opened. |
openPosition.size | number | Position size in units (e.g. 10000 for 0.1 lots of EURUSD). |
openPosition.unrealizedPnl | number | Current unrealized profit/loss in account currency at the current price. Negative means the position is losing. |
openPosition.accruedSwap | number | Broker-reported financing/swap cost accrued since the position was opened, in account currency. 0 when the broker does not report it. |
Common pattern — skip entry while a position is open:
if (input.portfolio.openPosition !== null) {
return { action: 'HOLD', reason: 'Position already open', checks };
}
input.parameters — bot parameters
User-configured values. All values are unknown — always coerce before use.
const period = typeof input.parameters.emaPeriod === 'number' ? input.parameters.emaPeriod : 50;
const label = typeof input.parameters.label === 'string' ? input.parameters.label : '';
const enabled = input.parameters.enabled === true;
Never assume a parameter exists or has the right type. Provide a sensible fallback for every parameter read.
input.context — evaluation metadata
| Field | Type | Description |
|---|---|---|
context.index | number | Index of the current candle in history. For QuickJS strategies this is relative to the windowed history slice; for native strategies it is the absolute index from candle 0. Always use history.length - 1 to access the current candle rather than context.index. |
context.totalCandles | number | Total number of candles available for this evaluation (length of the full series, not just the window). |
context.timeframe | string | Active timeframe string, e.g. '5m', '1h', '4h', '1d'. |
context.strategy.name | string | Name of the strategy as registered. |
context.strategy.version | string | Version string of the strategy. |
context.strategy.runtime | string | Runtime type: 'native' for built-in TypeScript strategies, 'remote' for HTTP strategies, 'wasm' for WASM strategies. |
context.strategy.language | string | Language string, e.g. 'typescript', 'javascript'. |
input.execution — system execution context
Injected by the runtime on every evaluation. Always present. Cannot be removed or overridden by strategy parameters or AI-generated configuration. Use these values for all cost and timing decisions.
input.execution.market — symbol market data
| Field | Type | Description |
|---|---|---|
market.pipSize | number | null | The pip size for this symbol. EURUSD, GBPUSD etc: 0.0001. JPY pairs (USDJPY, EURJPY): 0.01. null for crypto and stocks where pips are not applicable. Always null-check: const pip = input.execution.market.pipSize ?? 0.0001; |
market.previousClose | number | null | Close price of the candle immediately before the current candle. null at the very start of history when there is no prior candle. |
market.lotSize | number | Units per standard lot for this symbol. Forex: 100000. Crypto and stocks: 1. Used to convert a lot count to units: units = lots * lotSize. |
market.minLotSize | number | Minimum trade size in lots. Forex: 0.01. BTC/ETH: 0.001. Most other crypto: 0.01. Stocks: 1 (whole shares). |
market.lotStep | number | Minimum increment between valid lot sizes. Typically equal to minLotSize. Round calculated lot sizes to this: Math.floor(lots / lotStep) * lotStep. |
Position sizing example:
const pip = input.execution.market.pipSize ?? 0.0001;
const lotSize = input.execution.market.lotSize; // 100000 for forex
const balance = input.portfolio.cash;
const stopPips = 20;
const riskAmount = balance * 0.01; // risk 1% of account
const rawLots = riskAmount / (stopPips * pip * lotSize);
const step = input.execution.market.lotStep;
const lots = Math.max(input.execution.market.minLotSize, Math.floor(rawLots / step) * step);
input.execution.costs — execution cost assumptions
| Field | Type | Description |
|---|---|---|
costs.spreadPips | number | Assumed round-trip spread in pips (entry + exit half-spread combined). 0 when spread simulation is disabled. |
costs.slippagePips | number | Assumed round-trip slippage in pips. 0 when slippage simulation is disabled. |
costs.commission | object | Commission model for this broker/account. See sub-fields below. |
costs.fees | object | Additional fee model (exchange fees, etc.). See sub-fields below. |
costs.commission — commission model
| Field | Type | Description |
|---|---|---|
commission.type | string | Billing structure. One of: 'none' (no commission), 'per_lot' (fixed per standard lot per side), 'percent_notional' (percentage of trade value), 'per_trade_flat' (flat fee per trade). |
commission.perLotCost | number | undefined | When type === 'per_lot': cost in account currency per standard lot, per side. Round-trip cost = perLotCost × 2. Example: Pepperstone Razor = 3.50 USD/lot/side. |
commission.lotSize | number | undefined | When type === 'per_lot': units constituting one standard lot. Typically 100000 for forex. |
commission.percentNotional | number | undefined | When type === 'percent_notional': fraction of position notional charged per side. Example: 0.0001 = 0.01%. Round-trip = × 2. |
commission.perTradeCost | number | undefined | When type === 'per_trade_flat': flat fee in account currency for the round trip (both sides). |
costs.fees — additional fee model
| Field | Type | Description |
|---|---|---|
fees.type | string | One of: 'none', 'percent_notional', 'per_trade_flat'. |
fees.percentNotional | number | undefined | When type === 'percent_notional': fraction of notional charged round-trip. |
fees.perTradeCost | number | undefined | When type === 'per_trade_flat': flat fee per completed trade in account currency. |
Net-profit filter example:
const pip = input.execution.market.pipSize ?? 0.0001;
const roundTripCostPips = input.execution.costs.spreadPips + input.execution.costs.slippagePips;
const entryPrice = input.portfolio.openPosition.entryPrice;
const grossPips = input.portfolio.openPosition.type === 'BUY'
? (input.candle.close - entryPrice) / pip
: (entryPrice - input.candle.close) / pip;
if (grossPips - roundTripCostPips <= 0) {
return { action: 'HOLD', reason: 'No net edge after spread and slippage', checks };
}
input.execution.bot — bot identity and properties
| Field | Type | Description |
|---|---|---|
bot.id | string | null | Bot database ID (UUID). null when running inside a backtest. |
bot.name | string | null | Bot display name as set by the user. null in backtests. |
bot.symbol | string | Trading symbol, e.g. 'EURUSD', 'BTC-USD', 'AAPL'. |
bot.assetClass | string | Asset class: 'forex', 'crypto', or 'stock'. |
bot.exchange | string | Exchange or broker identifier, e.g. 'oanda', 'pepperstone', 'binance'. |
bot.isPaper | boolean | true when running in paper-trading (simulated) mode. false for live trading. |
bot.allocatedBalance | number | null | Capital budget assigned to this bot in account currency. null when no budget has been configured. |
bot.currentBalance | number | null | Current available balance in account currency. Same as portfolio.cash. null when not tracked. |
bot.status | 'active' | 'paused' | 'stopped' | null | Operational status. null in backtests. |
input.execution.schedule — trading schedule
| Field | Type | Description |
|---|---|---|
schedule.allowed | boolean | true when trading is currently permitted by the user's schedule and economic calendar settings. Always true in backtests (the runner pre-filters). |
schedule.reason | string | null | Human-readable reason why trading is blocked. null when allowed is true. |
schedule.upcomingEvents | object[] | Economic calendar events in the next 24 hours that match the user's blackout settings. Empty array in backtests or when no calendar is configured. |
schedule.upcomingEvents[] — each event
| Field | Type | Description |
|---|---|---|
event.title | string | Event name, e.g. 'Non-Farm Payrolls', 'CPI m/m', 'FOMC Statement'. |
event.country | string | Currency code of the affected country, e.g. 'USD', 'EUR', 'GBP'. |
event.impact | string | Impact rating: 'High', 'Medium', or 'Low'. |
event.eventTime | string | ISO timestamp of when the event is scheduled. |
event.minutesUntil | number | Minutes from now until the event fires. 0 if the event is currently active. |
Pre-event avoidance example:
const upcomingHigh = input.execution.schedule.upcomingEvents
.filter(e => e.impact === 'High' && e.minutesUntil <= 60);
if (upcomingHigh.length > 0) {
return { action: 'HOLD', reason: `High-impact event in ${upcomingHigh[0].minutesUntil} min: ${upcomingHigh[0].title}`, checks };
}
input.execution.clock — time context
The clock gives the exact evaluation time with pre-computed trading session flags. Use this instead of trying to parse candle.time — the QuickJS sandbox does not support new Date().
| Field | Type | Description |
|---|---|---|
clock.evaluatedAt | string | ISO timestamp of this evaluation. In live mode: the actual server time. In backtest: the current candle's close time. |
clock.dayOfWeek | number | Day of week in UTC: 1 = Monday, 2 = Tuesday, … 7 = Sunday (ISO 8601). |
clock.hourUTC | number | Current hour in UTC, 0–23. |
clock.minuteUTC | number | Current minute in UTC, 0–59. |
clock.isWeekend | boolean | true on Saturday (dayOfWeek === 6) or Sunday (dayOfWeek === 7). |
clock.isLondonSession | boolean | true when hourUTC is 8–15 (08:00–16:00 UTC) and it is not a weekend. |
clock.isNewYorkSession | boolean | true when hourUTC is 13–20 (13:00–21:00 UTC) and it is not a weekend. |
clock.isTokyoSession | boolean | true when hourUTC is 0–8 (00:00–09:00 UTC) and it is not a weekend. |
clock.isPacificSession | boolean | true when hourUTC is 21–23 or 0–6 (21:00–07:00 UTC) and it is not a weekend. |
Note: London–NY overlap is 13:00–16:00 UTC (isLondonSession && isNewYorkSession).
Tokyo–London overlap is 08:00–09:00 UTC (isTokyoSession && isLondonSession).
Session filter example:
const { isLondonSession, isNewYorkSession, isWeekend, dayOfWeek, hourUTC } = input.execution.clock;
// Only trade during high-liquidity sessions
if (!isLondonSession && !isNewYorkSession) {
return { action: 'HOLD', reason: 'Outside London/NY sessions', checks };
}
// Avoid Friday after 20:00 UTC (market wind-down)
if (dayOfWeek === 5 && hourUTC >= 20) {
return { action: 'HOLD', reason: 'Friday wind-down', checks };
}
input.execution.session — performance counters
Use these for risk management rules like daily trade limits, drawdown stops, and loss-streak pauses.
| Field | Type | Description |
|---|---|---|
session.dailyTradeCount | number | Number of trades completed today (UTC day). In backtest: accurate running count. In live mode: 0 (not yet tracked per-day; use allTimeTrades as a proxy). |
session.dailyPnl | number | Realized P&L from trades closed today in account currency. In backtest: accurate. In live mode: 0. |
session.allTimeTrades | number | Total trades completed by this bot across all time. |
session.allTimePnl | number | Total net P&L across all time in account currency. |
session.allTimeWinRate | number | Lifetime win rate as a percentage, 0–100. Example: 62.5 means 62.5% of trades were profitable. |
session.consecutiveLosses | number | Number of consecutive losing trades immediately before this evaluation. 0 if the last trade was a win or no trades have been taken. In backtest: accurate. In live: 0. |
session.consecutiveWins | number | Number of consecutive winning trades immediately before this evaluation. In backtest: accurate. In live: 0. |
Risk management examples:
// Max 3 trades per day (backtest)
if (input.execution.session.dailyTradeCount >= 3) {
return { action: 'HOLD', reason: 'Daily trade limit reached', checks };
}
// Pause after 3 consecutive losses
if (input.execution.session.consecutiveLosses >= 3) {
return { action: 'HOLD', reason: 'Loss streak protection (3 losses)', checks };
}
// Daily drawdown stop
if (input.execution.session.dailyPnl < -50) {
return { action: 'HOLD', reason: 'Daily loss limit reached ($50)', checks };
}
Return value — StrategyDecision
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
action | 'BUY' | 'SELL' | 'HOLD' | Yes | The decision. Must be uppercase. 'BUY' opens a long, 'SELL' opens a short (or closes a long), 'HOLD' takes no action. |
reason | string | Recommended | Human-readable explanation shown in the Monitor and backtest log. Keep it concise and informative. |
checks | object[] | Yes | Array of rule evaluations. Must be non-empty. Returned on every tick — not only when BUY/SELL fires. |
metadata | object | Optional | Arbitrary supporting data attached to the decision. Not displayed in the UI but stored in the decision log. |
size | number | Optional | Desired position size in units. When omitted, the bot uses its configured tradeQuantity or risk-based sizing. |
checks[] — each check object
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Stable machine key in snake_case. Never change it after deploy — used as the React list key. Examples: rsi_oversold, price_below_ema, history_ready. |
label | string | Yes | Human-readable rule description shown in the Monitor. Examples: 'RSI(14) < 40 (oversold)', 'Price below EMA(50)'. |
passed | boolean | Yes | true if this gate passed, false if it blocked the signal. |
value | string | number | null | Optional | The live value that drove the pass/fail, e.g. 27.4, 1.08420, 'bearish'. Omit for boolean-only gates. |
decision | 'BUY' | 'SELL' | 'HOLD' | Directional checks | Tag every check that belongs to a specific trade direction. Omit for shared preconditions (warmup guards, indicator null-checks, schedule). |
Strategy Checks Standard
The decision field — required on every directional check
Every check in checks[] that is specific to a BUY or SELL condition must
carry a decision field. This powers the Monitor sidebar and Backtest sidebar
to group checks into clear BUY / SELL / Shared sections so users can see
at a glance which criteria apply to which trade direction.
// ✅ CORRECT — directional checks are tagged
checks: [
// Shared (no `decision`) — applies to both BUY and SELL evaluation
{ name: 'history_ready', label: 'Sufficient history (≥ 50 candles)', passed: idx >= 50, value: idx },
{ name: 'ema_available', label: 'EMA(50) computed', passed: ema !== null, value: ema },
{ name: 'in_schedule', label: 'Within trading schedule', passed: inSchedule },
// BUY checks — decision: 'BUY'
{ name: 'rsi_oversold', label: 'RSI(14) < 40 (oversold)', passed: rsi < 40, value: rsi, decision: 'BUY' },
{ name: 'price_below_ema', label: 'Price below EMA(50)', passed: price < ema, value: price, decision: 'BUY' },
{ name: 'macd_bull_cross', label: 'MACD bullish crossover', passed: macdBullCross, decision: 'BUY' },
{ name: 'in_bullish_fvg', label: 'Price in bullish Fair Value Gap', passed: inBullishFvg, decision: 'BUY' },
// SELL checks — decision: 'SELL'
{ name: 'rsi_overbought', label: 'RSI(14) > 60 (overbought)', passed: rsi > 60, value: rsi, decision: 'SELL' },
{ name: 'price_above_ema', label: 'Price above EMA(50)', passed: price > ema, value: price, decision: 'SELL' },
{ name: 'macd_bear_cross', label: 'MACD bearish crossover', passed: macdBearCross, decision: 'SELL' },
{ name: 'in_bearish_fvg', label: 'Price in bearish Fair Value Gap', passed: inBearishFvg, decision: 'SELL' },
]
// ❌ WRONG — no `decision` field, all checks render in a flat list
checks: [
{ name: 'rsi_oversold', label: 'RSI < 40', passed: rsi < 40, value: rsi },
{ name: 'rsi_overbought', label: 'RSI > 60', passed: rsi > 60, value: rsi },
]
Rules for assigning decision
| Check type | decision value |
|---|---|
| Evaluates a BUY entry condition (oversold, bullish crossover, sell-side sweep, bullish FVG, price below EMA, lower grid zone) | 'BUY' |
| Evaluates a SELL entry condition (overbought, bearish crossover, buy-side sweep, bearish FVG, price above EMA, upper grid zone) | 'SELL' |
| Evaluates exit or position management for an open trade | 'HOLD' |
| Shared precondition for all paths (warmup, indicator availability, trading schedule, position state) | omit decision entirely |
Emit ALL checks on EVERY tick
Do not only emit checks when a signal fires. The full checklist — including checks that failed — must be returned on every tick so the Monitor shows why the strategy held. Only the first failing gate causes a HOLD, but all gates are still evaluated and included.
// ✅ Build checks first, then compute the decision from them
const buyOk = rsi < rsiOversold && price < ema && macdBullCross;
const sellOk = rsi > rsiOverbought && price > ema && macdBearCross;
const checks = [
{ name: 'history', label: 'Sufficient history', passed: true, value: history.length },
{ name: 'rsi_oversold', label: 'RSI < 40', passed: rsi < 40, value: rsi, decision: 'BUY' },
{ name: 'below_ema', label: 'Price below EMA', passed: price < ema, value: price, decision: 'BUY' },
{ name: 'bull_macd', label: 'MACD bull cross', passed: macdBullCross, decision: 'BUY' },
{ name: 'rsi_overbought', label: 'RSI > 60', passed: rsi > 60, value: rsi, decision: 'SELL' },
{ name: 'above_ema', label: 'Price above EMA', passed: price > ema, value: price, decision: 'SELL' },
{ name: 'bear_macd', label: 'MACD bear cross', passed: macdBearCross, decision: 'SELL' },
];
if (buyOk) return { action: 'BUY', reason: '...', checks };
if (sellOk) return { action: 'SELL', reason: '...', checks };
return { action: 'HOLD', reason: '...', checks };
Naming conventions
nameis a stable machine key —snake_case, never changed once deployed, used as the React list key in the Monitor. Examples:rsi_oversold,sell_side_sweep,ema_available,in_bullish_fvg.labelis a human sentence describing the rule:RSI(14) < 40 (oversold),Price wicked into bullish FVG,EMA(50) computed.valueshows the live number or string that drove the pass/fail:27.4,1.16800,bearish,0.5 pips. Omit for boolean gates.
Migrating an existing strategy
When updating a strategy that already has checks[] but lacks decision fields,
add decision to every check that belongs to a specific trade direction. Leave
shared checks (warmup, indicator null-guards, position state) without the field.
// Before — flat list, all look the same in Monitor
{ name: 'rsi_below_40', label: 'RSI < 40', passed: rsi < 40, value: rsi }
{ name: 'rsi_above_60', label: 'RSI > 60', passed: rsi > 60, value: rsi }
// After — sectioned in Monitor
{ name: 'rsi_below_40', label: 'RSI(14) < 40 (oversold)', passed: rsi < 40, value: rsi, decision: 'BUY' }
{ name: 'rsi_above_60', label: 'RSI(14) > 60 (overbought)', passed: rsi > 60, value: rsi, decision: 'SELL' }
SOUL.md — document every check
Every check in checks[] must appear in SOUL.md under Checks It Must Return,
with its name, label, decision, and what it measures. Example:
## Checks It Must Return
| name | label | decision | what it measures |
|---|---|---|---|
| history_ready | Sufficient history (≥ 50 candles) | shared | warmup guard |
| ema_available | EMA(50) computed | shared | indicator null-guard |
| rsi_oversold | RSI(14) < 40 (oversold) | BUY | momentum confirmation for long entry |
| price_below_ema | Price below EMA(50) | BUY | trend filter for long entry |
| rsi_overbought | RSI(14) > 60 (overbought) | SELL | momentum confirmation for short entry |
| price_above_ema | Price above EMA(50) | SELL | trend filter for short entry |
Contract-First Bundle
MTF, scalping, and exit-loop strategies should use the bundled file format:
SOUL.md— user-facing strategy contract and the source of truth.utils.js— shared helpers only.bias.js— higher-timeframe/contextual bias.entry.js— buy/sell/HOLD entry gates and entry checks.exit.js— price-only exit loop and exit checks.
SOUL.md must include these sections:
- Purpose
- Algorithm
- Bias
- Entry
- Exit
- Utilities Needed
- Indicators Needed
- Step-by-Step Strategy
- Checks It Must Return
- Expected Behaviour and Metrics
Every named check returned by entry.js or exit.js must be documented in
SOUL.md under Checks It Must Return.
Forbidden APIs
The runtime executes strategy source inside a QuickJS WASM sandbox.
The following are NOT available — validate_strategy_code flags them:
| API | Why |
|---|---|
import / require | No module system inside the strategy sandbox |
fetch | Strategies must be pure functions of input |
eval, nested new Function | Runtime restriction |
setTimeout, setInterval | Strategy is invoked once per candle |
process.* | Not available in Workers |
new Date() | Blocked in QuickJS — use input.execution.clock instead |
Math.random() | Blocked — strategies must be deterministic |
Inline any helpers you need.
JavaScript-first syntax
Write plain JavaScript. The runtime has a best-effort compatibility pass
for some TypeScript constructs (simple annotations, as casts, generic
call-site hints), but do not rely on it. To stay safe:
- Avoid
interfaces in the strategy file — useconstwith literal shapes if you need them. - Keep type annotations small (
: number,: string). - Do not annotate complex generic returns.
If your code does not compile, validate_strategy_code returns the exact
syntax/runtime-contract error so you can fix and retry.
Workflow
validate_strategy_code({ codeText })— compile-check, no persist. Iterate untilok: true.test_strategy_code({ codeText, parameters? })— run the candidate against a synthetic 60-candle window and inspect theactionCountshistogram + last fewdecisions. Confirm BUY/SELL/HOLD distribution matches intent.update_strategy_code({ codeText })— persist a newbot_strategiesversion. Rejects code that fails validation.run_backtest({ startDate, endDate, codeText? })— replay against historical candles. Returns Markdown trade table + Mermaid equity chart. PasscodeTextto backtest a candidate without persisting.
Q.* indicator functions return SCALARS, not arrays
This is the most common source of bugs. Every Q.* function returns a single
scalar value (number) or a single object — never an array.
CORRECT usage:
const emaNow = Q.ema(history, emaPeriod); // returns a number, e.g. 1.0842
const rsiNow = Q.rsi(history, rsiPeriod); // returns a number, e.g. 54.2
const bbNow = Q.bb(history, bbPeriod, bbDev); // returns { lower, middle, upper, width, std }
const atrNow = Q.atr(history, atrPeriod); // returns a number
const adxNow = Q.adx(history, adxPeriod); // returns a number
To get the "previous" value, call the function again with a sliced history:
const emaPrev = history.length > 1
? Q.ema(history.slice(0, history.length - 1), emaPeriod)
: null;
WRONG — these patterns always produce null or undefined:
// ❌ Q.ema() returns a number like 1.0842, NOT an array
const emaValue = Q.ema(history, emaPeriod);
const emaNow = emaValue && emaValue.length > 0 // .length is undefined → false
? emaValue[emaValue.length - 1]
: null; // → ALWAYS null
// ❌ Q.bb() uses .middle, NOT .mid
const bandMid = bbNow.mid; // undefined — use bbNow.middle
Q.bb() object shape:
{ lower: number, middle: number, upper: number, width: number, std: number }
// Always use .middle — NOT .mid, NOT .avg, NOT .mean
Returns null only when there is insufficient candle data (warm-up period). Always
null-check before using: if (emaNow === null) return { action: 'HOLD', reason: 'Warming up' };
Runtime note
Custom strategy execution, validation, and synthetic testing are powered by
QuickJS WASM. Keep strategy files self-contained, deterministic, and sync.
No Cloudflare unsafe-eval compatibility flag is required for this runtime.
Examples
See projects/quantago/strategies/london-strategy/index.ts and
grid-trading-strategy/index.ts for complete reference implementations
that follow this contract: structured checks[], parameter coercion,
warm-up handling, and HOLD-while-position-open.