Skip to main content

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​

FieldTypeDescription
input.candleobjectThe current (most-recently-closed) candle. Identical to input.history[input.history.length - 1].
input.historyobject[]All candles available to the strategy, oldest first. The last element is the current candle.
input.mtfHistoryobject | undefinedMulti-timeframe candle histories keyed by timeframe string. Only present when the strategy declares MTF parameters.
input.portfolioobjectAccount state at evaluation time.
input.parametersobjectUser-configured bot parameters. All keys are unknown — always coerce with type guards.
input.contextobjectRuntime metadata about the current evaluation.
input.executionobjectSystem-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.

FieldTypeDescription
candle.timestringISO 8601 timestamp of the candle's open time (e.g. "2024-03-15T10:00:00.000Z").
candle.opennumberOpening price of the candle.
candle.highnumberHighest price reached during the candle.
candle.lownumberLowest price reached during the candle.
candle.closenumberClosing price. Use this as the current market price.
candle.volumenumberVolume 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​

FieldTypeDescription
portfolio.cashnumberAvailable cash in account currency. In live mode: the bot's current balance. In backtest: running balance after closed trades.
portfolio.equitynumberTotal equity = cash + unrealized P&L of any open position.
portfolio.openPositionobject | nullThe current open position, or null when flat. Always check for null before accessing sub-fields.
portfolio.lastTradeAtstring | nullISO timestamp of when the most recent trade was closed. null if no trades have been completed yet.

portfolio.openPosition — open position (when not null)​

FieldTypeDescription
openPosition.type'BUY' | 'SELL'Direction of the position: 'BUY' = long, 'SELL' = short.
openPosition.entryPricenumberThe fill price at which the position was opened.
openPosition.entryTimestringISO timestamp of when the position was opened.
openPosition.sizenumberPosition size in units (e.g. 10000 for 0.1 lots of EURUSD).
openPosition.unrealizedPnlnumberCurrent unrealized profit/loss in account currency at the current price. Negative means the position is losing.
openPosition.accruedSwapnumberBroker-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​

FieldTypeDescription
context.indexnumberIndex 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.totalCandlesnumberTotal number of candles available for this evaluation (length of the full series, not just the window).
context.timeframestringActive timeframe string, e.g. '5m', '1h', '4h', '1d'.
context.strategy.namestringName of the strategy as registered.
context.strategy.versionstringVersion string of the strategy.
context.strategy.runtimestringRuntime type: 'native' for built-in TypeScript strategies, 'remote' for HTTP strategies, 'wasm' for WASM strategies.
context.strategy.languagestringLanguage 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​

FieldTypeDescription
market.pipSizenumber | nullThe 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.previousClosenumber | nullClose price of the candle immediately before the current candle. null at the very start of history when there is no prior candle.
market.lotSizenumberUnits per standard lot for this symbol. Forex: 100000. Crypto and stocks: 1. Used to convert a lot count to units: units = lots * lotSize.
market.minLotSizenumberMinimum trade size in lots. Forex: 0.01. BTC/ETH: 0.001. Most other crypto: 0.01. Stocks: 1 (whole shares).
market.lotStepnumberMinimum 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​

FieldTypeDescription
costs.spreadPipsnumberAssumed round-trip spread in pips (entry + exit half-spread combined). 0 when spread simulation is disabled.
costs.slippagePipsnumberAssumed round-trip slippage in pips. 0 when slippage simulation is disabled.
costs.commissionobjectCommission model for this broker/account. See sub-fields below.
costs.feesobjectAdditional fee model (exchange fees, etc.). See sub-fields below.

costs.commission — commission model​

FieldTypeDescription
commission.typestringBilling 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.perLotCostnumber | undefinedWhen 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.lotSizenumber | undefinedWhen type === 'per_lot': units constituting one standard lot. Typically 100000 for forex.
commission.percentNotionalnumber | undefinedWhen type === 'percent_notional': fraction of position notional charged per side. Example: 0.0001 = 0.01%. Round-trip = × 2.
commission.perTradeCostnumber | undefinedWhen type === 'per_trade_flat': flat fee in account currency for the round trip (both sides).

costs.fees — additional fee model​

FieldTypeDescription
fees.typestringOne of: 'none', 'percent_notional', 'per_trade_flat'.
fees.percentNotionalnumber | undefinedWhen type === 'percent_notional': fraction of notional charged round-trip.
fees.perTradeCostnumber | undefinedWhen 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​

FieldTypeDescription
bot.idstring | nullBot database ID (UUID). null when running inside a backtest.
bot.namestring | nullBot display name as set by the user. null in backtests.
bot.symbolstringTrading symbol, e.g. 'EURUSD', 'BTC-USD', 'AAPL'.
bot.assetClassstringAsset class: 'forex', 'crypto', or 'stock'.
bot.exchangestringExchange or broker identifier, e.g. 'oanda', 'pepperstone', 'binance'.
bot.isPaperbooleantrue when running in paper-trading (simulated) mode. false for live trading.
bot.allocatedBalancenumber | nullCapital budget assigned to this bot in account currency. null when no budget has been configured.
bot.currentBalancenumber | nullCurrent available balance in account currency. Same as portfolio.cash. null when not tracked.
bot.status'active' | 'paused' | 'stopped' | nullOperational status. null in backtests.

input.execution.schedule — trading schedule​

FieldTypeDescription
schedule.allowedbooleantrue when trading is currently permitted by the user's schedule and economic calendar settings. Always true in backtests (the runner pre-filters).
schedule.reasonstring | nullHuman-readable reason why trading is blocked. null when allowed is true.
schedule.upcomingEventsobject[]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​

FieldTypeDescription
event.titlestringEvent name, e.g. 'Non-Farm Payrolls', 'CPI m/m', 'FOMC Statement'.
event.countrystringCurrency code of the affected country, e.g. 'USD', 'EUR', 'GBP'.
event.impactstringImpact rating: 'High', 'Medium', or 'Low'.
event.eventTimestringISO timestamp of when the event is scheduled.
event.minutesUntilnumberMinutes 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().

FieldTypeDescription
clock.evaluatedAtstringISO timestamp of this evaluation. In live mode: the actual server time. In backtest: the current candle's close time.
clock.dayOfWeeknumberDay of week in UTC: 1 = Monday, 2 = Tuesday, … 7 = Sunday (ISO 8601).
clock.hourUTCnumberCurrent hour in UTC, 0–23.
clock.minuteUTCnumberCurrent minute in UTC, 0–59.
clock.isWeekendbooleantrue on Saturday (dayOfWeek === 6) or Sunday (dayOfWeek === 7).
clock.isLondonSessionbooleantrue when hourUTC is 8–15 (08:00–16:00 UTC) and it is not a weekend.
clock.isNewYorkSessionbooleantrue when hourUTC is 13–20 (13:00–21:00 UTC) and it is not a weekend.
clock.isTokyoSessionbooleantrue when hourUTC is 0–8 (00:00–09:00 UTC) and it is not a weekend.
clock.isPacificSessionbooleantrue 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.

FieldTypeDescription
session.dailyTradeCountnumberNumber 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.dailyPnlnumberRealized P&L from trades closed today in account currency. In backtest: accurate. In live mode: 0.
session.allTimeTradesnumberTotal trades completed by this bot across all time.
session.allTimePnlnumberTotal net P&L across all time in account currency.
session.allTimeWinRatenumberLifetime win rate as a percentage, 0–100. Example: 62.5 means 62.5% of trades were profitable.
session.consecutiveLossesnumberNumber 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.consecutiveWinsnumberNumber 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​

FieldTypeRequiredDescription
action'BUY' | 'SELL' | 'HOLD'YesThe decision. Must be uppercase. 'BUY' opens a long, 'SELL' opens a short (or closes a long), 'HOLD' takes no action.
reasonstringRecommendedHuman-readable explanation shown in the Monitor and backtest log. Keep it concise and informative.
checksobject[]YesArray of rule evaluations. Must be non-empty. Returned on every tick — not only when BUY/SELL fires.
metadataobjectOptionalArbitrary supporting data attached to the decision. Not displayed in the UI but stored in the decision log.
sizenumberOptionalDesired position size in units. When omitted, the bot uses its configured tradeQuantity or risk-based sizing.

checks[] — each check object​

FieldTypeRequiredDescription
namestringYesStable machine key in snake_case. Never change it after deploy — used as the React list key. Examples: rsi_oversold, price_below_ema, history_ready.
labelstringYesHuman-readable rule description shown in the Monitor. Examples: 'RSI(14) < 40 (oversold)', 'Price below EMA(50)'.
passedbooleanYestrue if this gate passed, false if it blocked the signal.
valuestring | number | nullOptionalThe live value that drove the pass/fail, e.g. 27.4, 1.08420, 'bearish'. Omit for boolean-only gates.
decision'BUY' | 'SELL' | 'HOLD'Directional checksTag 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 typedecision 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​

  • name is 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.
  • label is a human sentence describing the rule: RSI(14) < 40 (oversold), Price wicked into bullish FVG, EMA(50) computed.
  • value shows 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:

APIWhy
import / requireNo module system inside the strategy sandbox
fetchStrategies must be pure functions of input
eval, nested new FunctionRuntime restriction
setTimeout, setIntervalStrategy 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 — use const with 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​

  1. validate_strategy_code({ codeText }) — compile-check, no persist. Iterate until ok: true.
  2. test_strategy_code({ codeText, parameters? }) — run the candidate against a synthetic 60-candle window and inspect the actionCounts histogram + last few decisions. Confirm BUY/SELL/HOLD distribution matches intent.
  3. update_strategy_code({ codeText }) — persist a new bot_strategies version. Rejects code that fails validation.
  4. run_backtest({ startDate, endDate, codeText? }) — replay against historical candles. Returns Markdown trade table + Mermaid equity chart. Pass codeText to 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.